Volumio Web Control Interface with Apache/Nginx Proxy
Disclaimer: This guide is provided by the community and is not officially supported by Volumio. Use it at your own risk. Always back up your configurations before making changes, and proceed with caution.
Overview
This guide explains how to create a web-based control interface for Volumio, complete with playback controls, volume adjustment, playlist management, source selection, and queue management. The setup uses Apache or Nginx as a reverse proxy for Volumio’s API and serves an HTML interface to interact with it.
Prerequisites
- Volumio installed and running on your device.
- A web server installed on a machine accessible to your Volumio server:
- Either Apache or Nginx.
- Basic familiarity with editing configuration files and using the terminal.
Step 1: Configure Your Web Server
Apache Configuration
Create a new virtual host file for Volumio, e.g., /etc/apache2/sites-available/volumio.conf
:
<VirtualHost *:80>
ServerName volumio.local
DocumentRoot "/var/www/html"
# Proxy API requests to the Volumio server
ProxyPass /api http://<volumio_ip>/api
ProxyPassReverse /api http://<volumio_ip>/api
# Proxy WebSocket connections
ProxyPass /socket ws://<volumio_ip>:3000/
ProxyPassReverse /socket ws://<volumio_ip>:3000/
# Serve album artwork
ProxyPass /albumart http://<volumio_ip>:3000/albumart
ProxyPassReverse /albumart http://<volumio_ip>:3000/albumart
# Error and access logs
ErrorLog ${APACHE_LOG_DIR}/volumio_error.log
CustomLog ${APACHE_LOG_DIR}/volumio_access.log combined
</VirtualHost>
Enable the site and restart Apache:
sudo a2ensite volumio
sudo systemctl restart apache2
Nginx Configuration
Create a new server block file for Volumio, e.g., /etc/nginx/sites-available/volumio
:
server {
listen 80;
server_name volumio.local;
# Proxy API requests to the Volumio server
location /api/ {
proxy_pass http://<volumio_ip>/api/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
# Proxy WebSocket connections
location /socket {
proxy_pass http://<volumio_ip>:3000/;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "Upgrade";
}
# Serve album artwork
location /albumart {
proxy_pass http://<volumio_ip>:3000/albumart;
proxy_set_header Host $host;
}
# Error and access logs
error_log /var/log/nginx/volumio_error.log;
access_log /var/log/nginx/volumio_access.log;
}
Enable the configuration and restart Nginx:
sudo ln -s /etc/nginx/sites-available/volumio /etc/nginx/sites-enabled/
sudo systemctl restart nginx
Step 2: Deploy the Web Interface
Save the following HTML code to /var/www/html/index.html
(or your server’s document root):
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Volumio Control</title>
<link rel="stylesheet" href="style.css">
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
</head>
<body>
<header>
<img src="volumio-logo.png" alt="Volumio Logo" class="logo">
<h1>Volumio Control Panel</h1>
</header>
<nav>
<a href="#now-playing">Now Playing</a>
<a href="#playlists">Playlists</a>
<a href="#sources">Sources</a>
<a href="#queue">Queue</a>
</nav>
<main>
<section id="now-playing" class="now-playing">
<h2>Now Playing</h2>
<img id="albumart" src="default-album-art.jpg" alt="Album Art" class="album-art">
<p><strong>Title:</strong> <span id="title">No Track Playing</span></p>
<p><strong>Artist:</strong> <span id="artist"></span></p>
</section>
<section id="playback-controls" class="playback-controls">
<h2>Playback Controls</h2>
<div class="controls">
<button onclick="sendCommand('prev')" title="Previous">
<i class="material-icons">skip_previous</i>
</button>
<button onclick="sendCommand('pause')" title="Pause">
<i class="material-icons">pause</i>
</button>
<button onclick="sendCommand('play')" title="Play">
<i class="material-icons">play_arrow</i>
</button>
<button onclick="sendCommand('stop')" title="Stop">
<i class="material-icons">stop</i>
</button>
<button onclick="sendCommand('next')" title="Next">
<i class="material-icons">skip_next</i>
</button>
</div>
<div class="volume-control">
<h3>Volume</h3>
<input type="range" id="volume" min="0" max="100" oninput="setVolume(this.value)">
<span id="volumeValue">50</span>
</div>
</section>
<section id="playlists" class="playlists">
<h2>Playlists</h2>
<select id="playlistSelect" class="styled-select">
<option>Loading Playlists...</option>
</select>
<button onclick="playPlaylist()" class="styled-button">Play Playlist</button>
</section>
<section id="sources" class="sources">
<h2>Sources</h2>
<select id="sourceSelect" class="styled-select">
<option>Loading Sources...</option>
</select>
<button onclick="browseSource()" class="styled-button">Browse Source</button>
</section>
<section id="browseResults" class="browse-results">
<h2>Browse Results</h2>
<p>Select a source from above and navigate its contents.</p>
</section>
<section id="queue" class="queue">
<h2>Playback Queue</h2>
<ul id="queueList">
<li>Queue is empty</li>
</ul>
</section>
</main>
<footer>
<p>Powered by <strong>Volumio</strong></p>
</footer>
<script src="script.js"></script>
</body>
</html>
Save the following CSS code to /var/www/html/style.css
(or your server’s document root):
/* General Styles */
body {
font-family: 'Roboto', sans-serif;
margin: 0;
padding: 0;
background-color: #282c34;
color: #ffffff;
line-height: 1.6;
text-align: center; /* Center-align all main content */
}
/* Header */
header {
background-color: #47B072;
color: #ffffff;
padding: 1rem 0;
}
header .logo {
width: 10vw;
max-width: 100px;
margin-bottom: 0.5rem;
}
header h1 {
margin: 0;
font-size: 1.8rem;
}
/* Navigation */
nav {
display: flex;
justify-content: center;
gap: 1rem; /* Space between navigation links */
margin: 1rem 0;
background-color: #3b3f46;
padding: 1rem;
border-radius: 8px;
}
nav a {
text-decoration: none;
color: #ffffff;
font-weight: 500;
padding: 0.5rem 1rem;
border-radius: 5px;
transition: background-color 0.3s ease, color 0.3s ease;
}
nav a:hover {
background-color: #47B072;
color: #282c34;
}
/* Sections */
section {
margin-bottom: 2rem;
padding: 1rem;
background-color: #3b3f46;
border-radius: 8px;
text-align: center;
width: 90%; /* Ensure responsiveness */
max-width: 800px; /* Limit maximum width */
margin-left: auto;
margin-right: auto;
}
/* Section Headings */
section h2 {
color: #47B072;
font-size: 1.5rem;
margin-bottom: 1rem;
}
/* Playback Controls Section */
.playback-controls {
display: flex;
flex-direction: column; /* Stack buttons vertically */
align-items: center;
gap: 1.5rem; /* Space between buttons and volume control */
}
.playback-controls .controls {
display: flex;
gap: 1rem; /* Space between buttons */
}
.playback-controls button {
margin: 0.5rem;
padding: 0.6rem;
background-color: transparent;
color: #47B072;
border: 2px solid #47B072;
border-radius: 50%;
width: 50px;
height: 50px;
display: flex;
justify-content: center;
align-items: center;
cursor: pointer;
transition: background-color 0.3s ease, color 0.3s ease, transform 0.2s ease;
}
.playback-controls button:hover {
background-color: #47B072;
color: #ffffff;
transform: scale(1.1);
}
/* Volume Control */
.volume-control {
display: flex;
flex-direction: column; /* Stack slider and text vertically */
align-items: center;
gap: 0.5rem; /* Space between slider and label */
}
.volume-control input[type="range"] {
width: 300px;
height: 8px;
background: #47B072;
border-radius: 5px;
outline: none;
cursor: pointer;
-webkit-appearance: none;
}
.volume-control input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 20px;
height: 20px;
background: #47B072;
border-radius: 50%;
cursor: pointer;
transition: transform 0.2s ease;
}
.volume-control input[type="range"]::-webkit-slider-thumb:hover {
transform: scale(1.2);
}
/* Now Playing Section */
.now-playing .album-art {
width: 200px;
height: 200px;
border-radius: 10px;
object-fit: cover;
margin-bottom: 1rem;
}
/* Dropdown and Buttons */
.styled-select, .styled-button {
padding: 0.5rem;
font-size: 1rem;
border-radius: 5px;
cursor: pointer;
transition: background-color 0.3s ease, transform 0.2s ease;
}
.styled-select {
background-color: #3b3f46;
color: #ffffff;
border: 2px solid #47B072;
}
.styled-button {
background-color: #47B072;
color: #ffffff;
border: none;
}
.styled-button:hover {
background-color: #3e9d61;
transform: scale(1.05);
}
/* Browse Results Section */
#browseResults ul {
list-style: none;
padding: 0;
margin: 0;
}
#browseResults li {
display: grid;
grid-template-columns: 80px 80px auto; /* Fixed-width columns for alignment */
gap: 1rem;
align-items: center;
padding: 0.5rem 0;
border-bottom: 1px solid #444;
}
#browseResults li span {
text-align: left;
color: #ffffff;
}
#browseResults li:last-child {
border-bottom: none;
}
/* Buttons in Results */
#browseResults li button {
padding: 0.4rem 0.8rem;
background-color: #47B072;
color: #ffffff;
border: none;
border-radius: 5px;
cursor: pointer;
transition: background-color 0.3s ease, transform 0.2s ease;
}
#browseResults li button:hover {
background-color: #3e9d61;
transform: scale(1.05);
}
/* Footer */
footer {
margin-top: 2rem;
padding: 1rem 0;
background-color: #3b3f46;
color: #ffffff;
font-size: 0.9rem;
}
Save the following JavaScript code to /var/www/html/script.js
(or your server’s document root):
const API_BASE = '/api/v1';
// Fetch playlists
async function fetchPlaylists() {
const playlistSelect = document.getElementById('playlistSelect');
try {
const response = await fetch(`${API_BASE}/listplaylists`);
const playlists = await response.json();
playlistSelect.innerHTML = playlists.map(name => `<option value="${name}">${name}</option>`).join('');
} catch (error) {
console.error('Error fetching playlists:', error);
playlistSelect.innerHTML = '<option>Error Loading Playlists</option>';
}
}
// Play a selected playlist
async function playPlaylist() {
const playlist = document.getElementById('playlistSelect').value;
if (!playlist) {
console.warn('No playlist selected.');
return;
}
try {
console.log(`Playing playlist: ${playlist}`);
await fetch(`${API_BASE}/commands/?cmd=playplaylist&name=${encodeURIComponent(playlist)}`);
fetchQueue(); // Refresh queue after playing
} catch (error) {
console.error(`Error playing playlist "${playlist}":`, error);
}
}
// Fetch and browse sources
async function fetchSources() {
const sourceSelect = document.getElementById('sourceSelect');
try {
const response = await fetch(`${API_BASE}/browse`);
const data = await response.json();
sourceSelect.innerHTML = data.navigation.lists
.map(source => `<option value="${source.uri}">${source.name}</option>`)
.join('');
} catch (error) {
console.error('Error fetching sources:', error);
sourceSelect.innerHTML = '<option>Error Loading Sources</option>';
}
}
// Browse a selected source
async function browseSource() {
const source = document.getElementById('sourceSelect').value;
if (!source) {
console.warn('No source selected.');
return;
}
try {
console.log(`Browsing source: ${source}`);
const response = await fetch(`${API_BASE}/browse?uri=${encodeURIComponent(source)}`);
const data = await response.json();
if (data.navigation && data.navigation.lists) {
renderBrowseResults(data.navigation.lists);
} else {
console.warn('Invalid response structure:', data);
document.getElementById('browseResults').innerHTML = '<p>No results found.</p>';
}
} catch (error) {
console.error('Error browsing source:', error);
document.getElementById('browseResults').innerHTML = '<p>Error Browsing Source</p>';
}
}
// Render browse results dynamically
function renderBrowseResults(lists) {
const browseSection = document.getElementById('browseResults');
browseSection.innerHTML = '';
if (!lists || lists.length === 0) {
browseSection.innerHTML = '<p>No results found.</p>';
return;
}
lists.forEach(list => {
const section = document.createElement('div');
const itemsHTML = list.items
? list.items
.map(item => {
const name = item.name || item.title || 'Unnamed Item';
const uri = item.uri
? `<button onclick="addToQueueAndPlay('${item.uri}', '${name}')">Play</button>
<button onclick="navigateSource('${item.uri}')">Browse</button>`
: name;
return `<li>${uri} - ${name}</li>`;
})
.join('')
: '<li>No items available</li>';
section.innerHTML = `<h3>${list.title || 'Results'}</h3><ul>${itemsHTML}</ul>`;
browseSection.appendChild(section);
});
}
// Navigate deeper into a source
async function navigateSource(uri) {
if (!uri) {
console.warn('Invalid URI for navigation.');
return;
}
try {
console.log(`Navigating source: ${uri}`);
const response = await fetch(`${API_BASE}/browse?uri=${encodeURIComponent(uri)}`);
const data = await response.json();
renderBrowseResults(data.navigation.lists);
} catch (error) {
console.error('Error navigating source:', error);
}
}
// Add an item to queue and play
async function addToQueueAndPlay(uri, title = 'Unknown Item') {
const payload = [
{
uri: uri,
service: uri.startsWith('http') ? 'webradio' : 'mpd',
type: uri.startsWith('http') ? 'webradio' : 'song',
title: title
}
];
try {
console.log(`Adding to queue: ${uri}`);
const addResponse = await fetch(`${API_BASE}/replaceAndPlay`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ list: payload, index: 0 })
});
const addResult = await addResponse.json();
if (addResult.response !== 'success') {
console.error('Failed to add to queue and play:', addResult);
}
} catch (error) {
console.error('Error adding to queue and playing:', error);
}
}
// Fetch and display the playback queue
async function fetchQueue() {
try {
const response = await fetch(`${API_BASE}/getQueue`);
const queue = await response.json();
const queueList = document.getElementById('queueList');
queueList.innerHTML = queue.queue.map(
track => `<li>${track.name || 'Unknown'} - ${track.artist || 'Unknown Artist'}</li>`
).join('');
} catch (error) {
console.error('Error fetching queue:', error);
}
}
// Fetch and display playback state
async function fetchVolumioState() {
try {
const response = await fetch(`${API_BASE}/getState`);
const state = await response.json();
// Update now-playing section
document.getElementById('title').innerText = state.title || 'No Track Playing';
document.getElementById('artist').innerText = state.artist || '';
const albumArt = state.albumart || 'default-album-art.jpg';
document.getElementById('albumart').src = albumArt.startsWith('http') ? albumArt : `http://${location.host}${albumArt}`;
} catch (error) {
console.error('Error fetching Volumio state:', error);
}
}
// Send playback commands
async function sendCommand(command) {
try {
console.log(`Sending command: ${command}`);
await fetch(`${API_BASE}/commands/?cmd=${command}`);
fetchVolumioState(); // Refresh state after command
} catch (error) {
console.error(`Error sending command "${command}":`, error);
}
}
// Set playback volume
async function setVolume(value) {
try {
document.getElementById('volumeValue').innerText = value;
await fetch(`${API_BASE}/commands/?cmd=volume&volume=${value}`);
} catch (error) {
console.error('Error setting volume:', error);
}
}
// Initialize the interface
function initializeInterface() {
fetchPlaylists();
fetchSources();
fetchVolumioState();
setInterval(fetchQueue, 5000); // Refresh queue every 5 seconds
setInterval(fetchVolumioState, 5000); // Refresh playback state every 5 seconds
}
window.onload = initializeInterface;
Features
- Playback Controls: Play, pause, stop, next, and previous.
- Volume Adjustment: Slider to adjust volume.
- Playlist Management: Load playlists from Volumio.
- Source Browsing: View and browse sources.
- Queue Management: Display and manage the playback queue.
Accessing the Interface
- Open a browser and navigate to your web server’s domain/IP.
- Use the provided controls to manage Volumio.
Disclaimer: This is a community guide and is not officially supported by Volumio. Use at your own risk.
Kind Regards,