TeddyCloud Tonie source switcher gadget

It is fun to assign streaming radio stations to a Tonie, however given the number of options available, making a dedicated tonie for each is not very practical. It is much more convenient to use a single Tonie and simply switch its source every once in a while to something new.

Doing it in TeddyCloud is a bit tedious, however, and to simplify the experience I made a small webpage, which let me switch the source of a Tonie with a single click (fun fact, I actually asked ChatGPT to write it for me).

This, however, was still not kid-friendly, so I upgraded this to a small physical “Tonie source switcher” gadget, made out of an M5StickC Plus.

Here’s how it works. Here’s the code.

One funny variation of the idea is to stick an NFC tag into the M5Stick, and end up with a “smart” tonie, which can be configured to represent any source.

9 Likes

Hi,

what a neat little device :slight_smile:

Is your project also compatible with the newer version “M5StickC Plus2” ??

I didn’t try, but given that Plus2 should only differ from Plus in terms of memory and battery, I see no reason why it wouldn’t.

Nice project!

Could there also be way to import all available tonie tafs with naming to the selection automatically? plus radio stations manually from file as now??

Sure. Just adapt the given code from @K_T to fit your needs. You might want to use this endpoint to fetch your tonies from http://<TeddyCloud>/api/getTagIndex.

Yes I see, but cannot code python by myself :smiley:

I went to the trouble of creating an HTML version of the “Radio Switcher”.

The appearance:

These lines must be adapted to the specific circumstances.

Here’s the code. Simply save it as Radio Switcher.html

Here is a link to the code…

Tonie Konfigurator * { margin: 0; padding: 0; box-sizing: border-box; font-family: Arial, sans-serif; }
    body {
        background-color: #f0f0f0;
        display: flex;
        justify-content: center;
        align-items: center;
        min-height: 100vh;
        padding: 20px;
        touch-action: manipulation;
    }
    
    .device-container {
        width: 320px;
        height: 480px;
        background-color: #222;
        border-radius: 20px;
        box-shadow: 0 10px 25px rgba(0, 0, 0, 0.5);
        overflow: hidden;
        position: relative;
        display: flex;
        flex-direction: column;
    }
    
    .screen {
        width: 100%;
        height: 100%;
        background-color: white;
        display: flex;
        flex-direction: column;
        position: relative;
    }
    
    .screen-header {
        background-color: #f5f5f5;
        padding: 12px;
        border-bottom: 1px solid #ddd;
        font-weight: bold;
        font-size: 16px;
        text-align: center;
    }
    
    .screen-content {
        flex: 1;
        overflow: hidden;
        position: relative;
        display: flex;
        flex-direction: column;
        align-items: center;
    }
    
    .tonie-image-container {
        width: 135px;
        height: 180px;
        margin: 20px auto;
        border: 1px solid #eee;
        display: flex;
        justify-content: center;
        align-items: center;
        overflow: hidden;
        border-radius: 10px;
    }
    
    .tonie-image {
        width: 100%;
        height: 100%;
        object-fit: cover;
    }
    
    .instructions {
        padding: 15px;
        font-size: 14px;
        color: #666;
        line-height: 1.5;
        text-align: center;
        width: 100%;
    }
    
    .list-container {
        height: 100%;
        overflow-y: auto;
        padding: 5px 0;
        width: 100%;
    }
    
    .list-item {
        padding: 15px 20px;
        border-bottom: 1px solid #eee;
        cursor: pointer;
        transition: background-color 0.2s;
        font-size: 16px;
    }
    
    .list-item.selected {
        background-color: #e6f7ff;
        color: #1890ff;
        font-weight: bold;
        animation: pulse 1s infinite;
    }
    
    @keyframes pulse {
        0% { transform: translateX(0); }
        50% { transform: translateX(3px); }
        100% { transform: translateX(0); }
    }
    
    .buttons {
        display: flex;
        justify-content: space-between;
        padding: 15px;
        background-color: #f5f5f5;
        border-top: 1px solid #ddd;
        gap: 10px;
    }
    
    .button {
        padding: 20px 16px;
        background-color: #4CAF50;
        border: none;
        border-radius: 10px;
        cursor: pointer;
        font-size: 16px;
        font-weight: bold;
        color: white;
        transition: all 0.2s;
        flex: 1;
        min-height: 60px;
        display: flex;
        align-items: center;
        justify-content: center;
        text-align: center;
        user-select: none;
        -webkit-user-select: none;
        touch-action: manipulation;
    }
    
    .button:hover {
        background-color: #45a049;
    }
    
    .button:active {
        background-color: #3d8b40;
        transform: scale(0.98);
    }
    
    .button-select {
        background-color: #2196F3;
    }
    
    .button-select:hover {
        background-color: #1976D2;
    }
    
    .button-select:active {
        background-color: #1565C0;
    }
    
    .button-back {
        background-color: #ff9800;
    }
    
    .button-back:hover {
        background-color: #f57c00;
    }
    
    .button-back:active {
        background-color: #ef6c00;
    }
    
    .hidden {
        display: none;
    }
    
    .notification {
        position: absolute;
        top: 50%;
        left: 50%;
        transform: translate(-50%, -50%);
        background-color: rgba(0, 0, 0, 0.8);
        color: white;
        padding: 20px 25px;
        border-radius: 12px;
        z-index: 100;
        text-align: center;
        animation: fadeInOut 2s forwards;
        font-size: 16px;
    }
    
    @keyframes fadeInOut {
        0% { opacity: 0; }
        20% { opacity: 1; }
        80% { opacity: 1; }
        100% { opacity: 0; }
    }
    
    .button-info {
        display: block;
        font-size: 12px;
        margin-top: 5px;
        opacity: 0.9;
        font-weight: normal;
    }
    
    .manual-mode {
        background: #e3f2fd;
        border: 1px solid #90caf9;
        color: #1565c0;
        padding: 15px;
        border-radius: 10px;
        margin: 10px;
        text-align: center;
    }
    
    .current-selection {
        background: #f8f9fa;
        padding: 10px;
        margin: 10px;
        border-radius: 8px;
        text-align: center;
        font-size: 14px;
    }
</style>
Tonies
Aktueller Tonie
Tonie 1 von 2

Wähle einen Tonie und wechsle die Quelle

Manueller Modus aktiv
Ă–ffne TeddyCloud und trage die URL manuell ein.
    <!-- Select Screen -->
    <div id="select-screen" class="screen hidden">
        <div class="screen-header">Quelle auswählen</div>
        <div class="screen-content">
            <div id="sources-list" class="list-container">
                <!-- Sources will be populated by JavaScript -->
            </div>
        </div>
    </div>
    
    <div class="buttons">
        <button id="btn-select" class="button button-select">
            Auswählen
            <span class="button-info">Klick: Wechseln | Halten: Bestätigen</span>
        </button>
        <button id="btn-back" class="button button-back">
            ZurĂĽck
            <span class="button-info">ZurĂĽck | Ausschalten</span>
        </button>
    </div>
    
    <div id="notification" class="notification hidden"></div>
</div>

<script>
    // Konfiguration
    const config = {
        TEDDYCLOUD_URL: "http://192.168.178.120:80",
        TONIES: [
            {img: "BerryCreativeTonie.jpg", tag_id: "f58ced0e500304e0"},
            {img: "FairyCreativeTonie.jpg", tag_id: "f58ced0e500304e0"},
        ],
        SOURCES: [
            {title: "TOGGO Radio", url: "http://radio.toggo.de/live/mp3-192/airable/"},
            {title: "Energy Zurich", url: "https://energyzuerich.ice.infomaniak.ch/energyzuerich-high.mp3"},
            {title: "Radio SantaClaus", url: "https://streaming.radiostreamlive.com/radiosantaclaus_devices"},
            {title: "Technobase.fm", url: "https://listener2.mp3.tb-group.fm/tb.mp3"},
        ]
    };

    // Touch-Handling Variablen
    let longPressTimeout = null;
    const LONG_PRESS_DURATION = 500;

    // Tonie-Klasse
    class Tonie {
        constructor(img, tag_id) {
            this.img = img;
            this.tag_id = tag_id;
        }

        async update_url(url) {
            console.log(`Versuche Tonie ${this.tag_id} zu aktualisieren mit: ${url}`);
            
            // Methode 1: Fetch ohne CORS (no-cors mode)
            try {
                const response = await fetch(`${config.TEDDYCLOUD_URL}/content/json/set/${this.tag_id}`, {
                    method: 'POST',
                    mode: 'no-cors',
                    headers: {
                        'Content-Type': 'application/x-www-form-urlencoded',
                    },
                    body: `source=${encodeURIComponent(url)}`
                });
                
                this.showNotification("âś… Anfrage gesendet");
                return true;
            } catch (error) {
                console.log('Methode 1 fehlgeschlagen:', error);
            }
            
            // Methode 2: Bild-Tag als Fallback
            try {
                return new Promise((resolve) => {
                    const img = new Image();
                    const apiUrl = `${config.TEDDYCLOUD_URL}/content/json/set/${this.tag_id}?source=${encodeURIComponent(url)}&_=${Date.now()}`;
                    
                    img.onload = () => {
                        this.showNotification("âś… Tonie aktualisiert");
                        resolve(true);
                    };
                    
                    img.onerror = () => {
                        this.showNotification("❌ Aktualisierung fehlgeschlagen");
                        resolve(false);
                    };
                    
                    img.src = apiUrl;
                    
                    setTimeout(() => {
                        if (!img.complete) {
                            this.showNotification("⚠️ Timeout - prüfe manuell");
                            resolve(false);
                        }
                    }, 3000);
                });
            } catch (error) {
                console.log('Methode 2 fehlgeschlagen:', error);
            }
            
            this.showNotification("❌ Automatische Methoden fehlgeschlagen");
            return false;
        }
        
        openManualSetup(url) {
            const manualMode = document.getElementById('manual-mode');
            manualMode.style.display = 'block';
            manualMode.innerHTML = `
                <strong>Manuelle Konfiguration nötig:</strong><br>
                <small>1. Ă–ffne: <a href="${config.TEDDYCLOUD_URL}" target="_blank">TeddyCloud</a></small><br>
                <small>2. Gehe zu Content Management</small><br>
                <small>3. Setze fĂĽr Tonie ${this.tag_id}:</small><br>
                <code style="background: #f5f5f5; padding: 5px; display: block; margin: 5px 0; font-size: 12px;">${url}</code>
                <button onclick="this.parentElement.style.display='none'" style="margin-top: 5px; padding: 5px 10px;">OK</button>
            `;
            
            this.showNotification("📋 Manuelle Konfiguration nötig");
        }
        
        showNotification(message) {
            const notification = document.getElementById('notification');
            notification.textContent = message;
            notification.classList.remove('hidden');
            
            setTimeout(() => {
                notification.classList.add('hidden');
            }, 3000);
        }
    }

    // SelectOption-Klasse
    class SelectOption {
        constructor(title, url) {
            this.title = title;
            this.url = url;
        }
    }

    // Screen-Management
    class ScreenManager {
        constructor() {
            this.currentScreen = null;
            this.screens = {
                tonies: document.getElementById('tonies-screen'),
                select: document.getElementById('select-screen')
            };
        }
        
        showScreen(screenName) {
            Object.values(this.screens).forEach(screen => {
                screen.classList.add('hidden');
            });
            
            this.screens[screenName].classList.remove('hidden');
            this.currentScreen = screenName;
        }
    }

    // App-Klasse
    class App {
        constructor(config) {
            this.config = config;
            this.screenManager = new ScreenManager();
            this.tonies = config.TONIES.map(t => new Tonie(t.img, t.tag_id));
            this.sources = config.SOURCES.map(s => new SelectOption(s.title, s.url));
            this.currentTonieIndex = 0;
            this.selectedSourceIndex = 0;
            
            this.init();
        }
        
        init() {
            console.log("App wird initialisiert...");
            
            // Event-Listener fĂĽr Buttons - VERBESSERTE VERSION
            const btnSelect = document.getElementById('btn-select');
            const btnBack = document.getElementById('btn-back');
            
            // Einfache Click-Events fĂĽr Desktop
            btnSelect.addEventListener('click', (e) => {
                console.log("Select Button geklickt");
                this.handleSelectClick();
            });
            
            btnBack.addEventListener('click', () => {
                console.log("Back Button geklickt");
                this.handleBackClick();
            });
            
            // Contextmenu fĂĽr Rechtsklick/Long Press auf Desktop
            btnSelect.addEventListener('contextmenu', (e) => {
                e.preventDefault();
                console.log("Select Button long press");
                this.handleSelectHold();
            });
            
            btnBack.addEventListener('contextmenu', (e) => {
                e.preventDefault();
                console.log("Back Button long press");
                this.handleBackHold();
            });
            
            // Touch Events fĂĽr Mobile
            btnSelect.addEventListener('touchstart', (e) => {
                e.preventDefault();
                console.log("Select Button touch start");
                longPressTimeout = setTimeout(() => {
                    console.log("Select Button long touch");
                    this.handleSelectHold();
                }, LONG_PRESS_DURATION);
            });
            
            btnSelect.addEventListener('touchend', (e) => {
                e.preventDefault();
                if (longPressTimeout) {
                    clearTimeout(longPressTimeout);
                    longPressTimeout = null;
                    console.log("Select Button short touch");
                    this.handleSelectClick();
                }
            });
            
            btnBack.addEventListener('touchstart', (e) => {
                e.preventDefault();
                console.log("Back Button touch start");
                longPressTimeout = setTimeout(() => {
                    console.log("Back Button long touch");
                    this.handleBackHold();
                }, LONG_PRESS_DURATION);
            });
            
            btnBack.addEventListener('touchend', (e) => {
                e.preventDefault();
                if (longPressTimeout) {
                    clearTimeout(longPressTimeout);
                    longPressTimeout = null;
                    console.log("Back Button short touch");
                    this.handleBackClick();
                }
            });
            
            // Touch bewegen = Long Press abbrechen
            btnSelect.addEventListener('touchmove', (e) => {
                if (longPressTimeout) {
                    clearTimeout(longPressTimeout);
                    longPressTimeout = null;
                }
            });
            
            btnBack.addEventListener('touchmove', (e) => {
                if (longPressTimeout) {
                    clearTimeout(longPressTimeout);
                    longPressTimeout = null;
                }
            });
            
            // Tonies Screen initialisieren
            this.updateTonieDisplay();
            this.populateSourcesList();
            
            // Standard-Screen anzeigen
            this.screenManager.showScreen('tonies');
            
            console.log("App initialisiert");
        }
        
        updateTonieDisplay() {
            const currentTonie = this.tonies[this.currentTonieIndex];
            const imgElement = document.getElementById('tonie-img');
            imgElement.src = currentTonie.img;
            imgElement.alt = `Tonie ${this.currentTonieIndex + 1}`;
            
            // Aktuelle Auswahl anzeigen
            document.getElementById('current-selection').textContent = 
                `Tonie ${this.currentTonieIndex + 1} von ${this.tonies.length}`;
            
            console.log(`Tonie aktualisiert: Index ${this.currentTonieIndex}`);
        }
        
        populateSourcesList() {
            const sourcesList = document.getElementById('sources-list');
            sourcesList.innerHTML = '';
            
            this.sources.forEach((source, index) => {
                const item = document.createElement('div');
                item.className = 'list-item';
                if (index === this.selectedSourceIndex) {
                    item.classList.add('selected');
                }
                item.textContent = source.title;
                item.addEventListener('click', () => {
                    console.log(`Quelle ausgewählt: ${source.title}`);
                    this.selectedSourceIndex = index;
                    this.populateSourcesList();
                });
                sourcesList.appendChild(item);
            });
            
            console.log(`Quellenliste aktualisiert, ausgewählt: ${this.selectedSourceIndex}`);
        }
        
        handleSelectClick() {
            console.log(`handleSelectClick aufgerufen, aktueller Screen: ${this.screenManager.currentScreen}`);
            
            if (this.screenManager.currentScreen === 'tonies') {
                // Tonie wechseln
                this.currentTonieIndex = (this.currentTonieIndex + 1) % this.tonies.length;
                this.updateTonieDisplay();
                console.log(`Tonie gewechselt zu: ${this.currentTonieIndex}`);
            } else if (this.screenManager.currentScreen === 'select') {
                // Quelle wechseln
                this.selectedSourceIndex = (this.selectedSourceIndex + 1) % this.sources.length;
                this.populateSourcesList();
                console.log(`Quelle gewechselt zu: ${this.selectedSourceIndex}`);
            }
        }
        
        async handleSelectHold() {
            console.log(`handleSelectHold aufgerufen, aktueller Screen: ${this.screenManager.currentScreen}`);
            
            if (this.screenManager.currentScreen === 'tonies') {
                // Zum Auswahlbildschirm wechseln
                this.screenManager.showScreen('select');
                console.log("Wechsle zu Auswahlbildschirm");
            } else if (this.screenManager.currentScreen === 'select') {
                // Quelle bestätigen und Tonie aktualisieren
                const selectedSource = this.sources[this.selectedSourceIndex];
                const currentTonie = this.tonies[this.currentTonieIndex];
                
                console.log(`Bestätige Auswahl: ${selectedSource.title} -> ${selectedSource.url}`);
                
                const success = await currentTonie.update_url(selectedSource.url);
                
                if (success) {
                    this.playTone();
                }
                
                // ZurĂĽck zum Tonies Screen
                setTimeout(() => {
                    this.screenManager.showScreen('tonies');
                    console.log("ZurĂĽck zu Tonie-Bildschirm");
                }, 1000);
            }
        }
        
        handleBackClick() {
            console.log(`handleBackClick aufgerufen, aktueller Screen: ${this.screenManager.currentScreen}`);
            
            if (this.screenManager.currentScreen === 'select') {
                this.screenManager.showScreen('tonies');
                // Verstecke manuellen Modus
                document.getElementById('manual-mode').style.display = 'none';
                console.log("ZurĂĽck zu Tonie-Bildschirm");
            }
        }
        
        handleBackHold() {
            console.log("handleBackHold aufgerufen - Ausschalten");
            if (confirm("Möchten Sie das Gerät wirklich ausschalten?")) {
                document.body.innerHTML = '<div style="display:flex;justify-content:center;align-items:center;height:100vh;background:#000;color:#fff;font-size:24px;">Gerät wird ausgeschaltet...</div>';
                
                setTimeout(() => {
                    document.body.innerHTML = '<div style="display:flex;justify-content:center;align-items:center;height:100vh;background:#000;color:#666;font-size:18px;">Gerät ausgeschaltet</div>';
                }, 2000);
            }
        }
        
        playTone() {
            try {
                const audioContext = new (window.AudioContext || window.webkitAudioContext)();
                const oscillator = audioContext.createOscillator();
                const gainNode = audioContext.createGain();
                
                oscillator.connect(gainNode);
                gainNode.connect(audioContext.destination);
                
                oscillator.frequency.value = 2000;
                oscillator.type = 'sine';
                
                gainNode.gain.setValueAtTime(0.3, audioContext.currentTime);
                gainNode.gain.exponentialRampToValueAtTime(0.01, audioContext.currentTime + 0.05);
                
                oscillator.start(audioContext.currentTime);
                oscillator.stop(audioContext.currentTime + 0.05);
            } catch (error) {
                console.log("Audio nicht verfĂĽgbar");
            }
        }
    }

    // App starten
    document.addEventListener('DOMContentLoaded', () => {
        console.log("DOM geladen, starte App...");
        new App(config);
    });
</script>

It’s not working perfectly everywhere yet…

I can swap radio stations on every PC, but not on my smartphone (even though it’s on the same Wi-Fi network as the TC).

But I’m still working on it…

Great idea. How long is the batterie life?

I had the idea to us a eInk M5Stack (M5Stack ESP32 Core Ink Development Kit (1.54’’ elnk display)). This saves batterie draw and the last selected audio will be visible on the display, while it’s in deep sleep.