#websocket #golang #android #kotlin #media-control #real-time #macos

Building a WebSocket Media Controller: Remote Control Your Mac from Android

· by Konstantino Vici

How I built a real-time media controller using WebSockets to control my Mac's music playback from my Android phone. A story of engineering fun and practical problem-solving.

Building a WebSocket Media Controller: Remote Control Your Mac from Android 🎵

There’s something deeply satisfying about solving a problem that’s been nagging at you for weeks. For me, that problem was this: I’m listening to FLAC music on my Mac M1 MacBook streaming to Bluetooth earphones, but every time I want to skip a song or adjust the volume, I have to walk over to my laptop. Not anymore!

The Annoying Problem

Picture this: You’re comfortably settled in your favorite spot, music playing perfectly through your Bluetooth headphones from your Mac. But then… the current track is just not doing it for you. Do you:

  1. Get up and walk to your laptop to skip the song?
  2. Leave your comfortable spot and interrupt your flow?

I chose neither. Instead, I decided to build a solution that would let me control my Mac’s media playback directly from my Android phone using WebSockets.

Why WebSockets? (The Sexy Choice)

When brainstorming solutions, I considered several approaches:

  • HTTP REST API: Simple but clunky for real-time control
  • Bluetooth: Complex and potentially conflicting with audio streaming
  • WebSockets: Real-time, bidirectional communication - the sexiest option!

WebSockets offered exactly what I needed: instant, bidirectional communication between my Android phone and Mac. No polling, no request/response delays - just pure real-time control.

The Tech Stack

For maximum fun and learning, I went with:

  • Go for the Mac server (fast, simple, battle-tested)
  • Kotlin for the Android client (modern Android development)
  • AppleScript for media control integration (the bridge to system functions)
  • Gorilla WebSocket library for Go (excellent WebSocket support)

The Mac Server Implementation

The heart of the solution is a Go server that listens for WebSocket connections and translates commands to AppleScript calls:

func handleMediaCommand(msg Message) map[string]interface{} {
    var result = make(map[string]interface{})
    result["action"] = msg.Action
    
    switch msg.Action {
    case "play", "pause":
        // Both play and pause use the same key code
        output, err := exec.Command("osascript", "-e", "tell application \"System Events\" to key code 49").CombinedOutput()
        result["success"] = err == nil
        result["output"] = string(output)
        
    case "next":
        output, err := exec.Command("osascript", "-e", "tell application \"System Events\" to key code 124 using {command down}").CombinedOutput()
        result["success"] = err == nil
        result["output"] = string(output)
        
    case "previous":
        output, err := exec.Command("osascript", "-e", "tell application \"System Events\" to key code 123 using {command down}").CombinedOutput()
        result["success"] = err == nil
        result["output"] = string(output)
        
    case "volume_up":
        output, err := exec.Command("osascript", "-e", "set volume output volume ((output volume of (get volume settings)) + 10)").CombinedOutput()
        result["success"] = err == nil
        result["output"] = string(output)
        
    case "volume_down":
        output, err := exec.Command("osascript", "-e", "set volume output volume ((output volume of (get volume settings)) - 10)").CombinedOutput()
        result["success"] = err == nil
        result["output"] = string(output)
        
    default:
        result["success"] = false
        result["error"] = "Unknown action"
    }
    
    return result
}

The server handles WebSocket connections and routes commands to the appropriate AppleScript execution, providing real-time feedback to the client.

The Android Client

The Kotlin Android app provides a clean interface with intuitive media controls:

private fun sendCommand(action: String) {
    if (!isConnected) {
        Toast.makeText(this, "Not connected to server", Toast.LENGTH_SHORT).show()
        return
    }
    
    val json = JSONObject()
    json.put("action", action)
    
    webSocket.send(json.toString())
}

The UI features large, touch-friendly buttons for play/pause, next/previous track, and volume control - perfect for quick interactions without looking at your screen.

Challenges and Solutions

Network Discovery

Problem: How does the Android app find the Mac on the network? Solution: Manual IP entry for now, but I’m planning to implement mDNS discovery for automatic detection.

Media Player Compatibility

Problem: Different media players might respond differently to AppleScript commands. Solution: The current implementation works with most standard media players that respond to system-level play/pause commands.

Connection Reliability

Problem: WiFi drops and sleep modes could interrupt control. Solution: Implemented proper connection state handling and reconnection logic in the Android client.

Performance and User Experience

The WebSocket approach delivers exceptional performance:

  • Response times: Nearly instant (<50ms) command execution
  • Battery impact: Minimal on both devices
  • Reliability: Stable connections with proper error handling

Using the controller feels natural and responsive - exactly what I was looking for when I started this project.

Future Enhancements

This is just the beginning! I’m planning to add:

  • Playlist control and track listing
  • Current track display with album art
  • Multiple device support
  • Cross-platform clients (iOS, web)
  • mDNS network discovery for automatic server detection
  • Enhanced security with authentication

Lessons Learned

Building this project taught me several valuable lessons:

  1. WebSockets are perfect for real-time control - The instant response makes for a much better user experience than HTTP polling
  2. AppleScript integration is powerful - Being able to control system functions through simple commands opens up many possibilities
  3. Go’s simplicity is a strength - The server code is clean, fast, and easy to understand
  4. Mobile development requires attention to UX details - Large touch targets and clear feedback are essential for a good remote control experience

Try It Yourself

The code is available on my GitHub, and setting it up is straightforward:

  1. Run the Go server on your Mac:

    cd media-controller
    go run main.go
    
  2. Build and install the Android app on your phone

  3. Connect using your Mac’s IP address

  4. Start controlling your music!

Conclusion

What started as a minor annoyance turned into an exciting engineering project that perfectly demonstrates the joy of building solutions to problems that matter to you. The WebSocket media controller is now an essential part of my daily workflow, and I’m excited to continue improving it.

Sometimes the best projects are the ones that solve your own problems - they come with built-in motivation and clear success criteria. This one definitely falls into that category!


Have you ever built something just because it annoyed you? I’d love to hear about your “scratch your own itch” projects in the comments below!