[Guide] Volumio Web Control Interface with Apache/Nginx Proxy

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

  1. Volumio installed and running on your device.
  2. A web server installed on a machine accessible to your Volumio server:
    • Either Apache or Nginx.
  3. 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,

Create a Branded Volumio Web Control Interface Using Docker Compose

Disclaimer: This guide is provided as is and is not supported by Volumio. Use it at your own risk. Always back up your configurations before proceeding.


Overview

This guide explains how to create a web-based control interface for Volumio using Docker Compose. The interface features branded styling with Volumio colors, playback controls, volume adjustment, playlist management, source selection, and queue management.


Prerequisites

  1. Volumio installed and running on your device.
  2. Docker and Docker Compose installed on your server.
  3. Basic familiarity with terminal commands and file editing.

Step 1: Directory Setup

Create a directory to hold the required files:

mkdir volumio-control
cd volumio-control

Inside this directory, create the following structure:

volumio-control/
├── docker-compose.yml
├── nginx/
│   └── default.conf
└── html/
    ├── index.html
    ├── style.css
    └── script.js

Step 2: Docker Compose Configuration

Create the docker-compose.yml file to define the Nginx service:

version: '3.8'

services:
  volumio-nginx:
    image: nginx:latest
    container_name: volumio-nginx
    volumes:
      - ./nginx/default.conf:/etc/nginx/conf.d/default.conf
      - ./html:/usr/share/nginx/html:ro
    ports:
      - "80:80"
    restart: unless-stopped

Step 3: Nginx Proxy Configuration

Create nginx/default.conf to route API and WebSocket requests to your Volumio device:

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;
    }

    # Serve the HTML interface
    location / {
        root /usr/share/nginx/html;
        index index.html;
    }

    error_log /var/log/nginx/volumio_error.log;
    access_log /var/log/nginx/volumio_access.log;
}

Replace <volumio_ip> with the IP address of your Volumio device.


Step 4: Web Interface Files

HTML File (html/index.html) from first post.


CSS File (html/style.css) from first post.


JavaScript File (html/script.js) from first post.


Step 5: Deploy and Access

  1. Start the Docker Compose stack:
    docker-compose up -d
    
  2. Open your browser and navigate to:
    • http://<server-ip>
    • Or http://volumio.local (if DNS is configured).

Note: This guide is provided as is and is not supported by Volumio. Use at your own risk.


Kind Regards,

Disclaimer: This guide is provided as is and is not supported by Volumio. This screenshot demonstrates working interface.

All functions are relying on Volumio REST APIs, and as such are not replacing native UI.

Note: This guide is provided as is and is not supported by Volumio nor via PMs. Use at your own risk.


Kind Regards,

Update: Volumio Web Control Proxy

Dear Volumionauts,
The Volumio Web Control Proxy project has been moved to a GitHub repository for easier access:
:point_right: GitHub Repository

Key Features:

  • Modern, user-friendly interface for controlling your Volumio system.
  • Integration with Volumio REST API for seamless browsing and playback.
  • Switchable styles (modern vs. classic) to match your preferences.
  • Dockerized setup for quick deployment.

Important Notes:

This project is not officially supported by Volumio or the Volumio team. Please refrain from contacting Volumio’s PMs or support channels regarding this project. For questions, suggestions, or contributions, kindly use the Issues or Discussions sections in the GitHub repository.

Your feedback and contributions are welcome.

Kind regards,

2 Likes