https://fly.io/blog/livebeats/ App performance optimization Open main menu Blog Phoenix Files Docs Community Pricing Sign In Get Started RSS Feed Reading time * 11 min Share this post on Twitter Share this post on Hacker News Share this post on Reddit LiveBeats: Building a social music app with Phoenix LiveView Author Chris McCord Name Chris McCord Twitter @chris_mccord [livebeats-] Fly.io is now free for small Phoenix projects. This post is about building a wicked LiveView app with realtime collaboration features. If you already have a Phoenix app to deploy, try us out. You can be up and running in just a few minutes. We decided that 2022 was a good year to ship a full-stack Phoenix reference app. The "full stack" metaphor has progressed beyond its humble beginnings of some REST endpoints and sprinkles of JS and CSS. Showing off a todo app is also no longer state of the art. A reference app should really stress a framework and match the needs of apps being built today. Remember turntable.fm? That's a more interesting challenge. A good full-stack framework should help you solve ALL the problems you need to build something like turntable.fm quickly, and then iteratively make it more powerful. Live updates are no longer optional, and a solo full stack developer should be able to deliver on these features with the same productivity of a CRUD Rails app in 2010. Meet LiveBeats, a social music application we wrote to show off the LiveView UX, while serving as a learning example and a test-bed for new LiveView features. As such, it is, of course, open source -- follow the development here! Musical interlude: Try LiveBeats now! If you're not familiar with LiveView, our overview states: LiveView strips away layers of abstraction, because it solves both the client and server in a single abstraction. HTTP almost entirely falls away. No more REST. No more JSON. No GraphQL APIs, controllers, serializers, or resolvers. You just write HTML templates, and a stateful process synchronizes it with the browser, updating it only when needed. And there's no JavaScript to write. Here are some of the things LiveBeats demonstrates: 1. A "live", shared UI. What one person does is visible to everyone else who's connected. 2. File management. Uploads should be quick, with a live UI. And the framework should make it easy to use different storage backends. Third party object storage is one way to do this, but sometimes development is simpler with just a filesystem. LiveView uploads is great for both! 3. Presence! Apps are more interesting when your friends show up. We can accomplish all this with just Phoenix and LiveView, in a shockingly small amount of code. It's also super fast. To really see what's special about LiveView and its new features, you need to experience it for yourself. Sign in with GitHub OAuth, upload a playlist of MP3's, and listen to music with your friends. Playback syncs in real-time, presence shows who is currently listening, and file uploads are processed concurrently as they are uploaded. Here's a two-minute demo to see it in action: Playlist Sync and Presence with Phoenix PubSub The hallmark of any "live" or social app is seeing your friends' activity as it happens. In our case, we want to show who is currently listening to a given playlist, and sync the playback of songs as the owner drives the song selection. This is what it looks like: Phoenix PubSub makes this trivial. In a few lines of code in our business logic, we broadcast updates, then with a few LOC in the LiveViews we listen for the events we care about. When they come in, we update the UI. Everyone connected sees the pages update, even if they are on different horizontally scaled servers. With friction-free PubSub at your fingertips, any feature that makes sense to be real-time gets to be real-time--even changing URLs on the fly. For example, user profiles are served at their username, such as livebeats.fly.dev/chrismccord. But we allow users to update their username in the app, which changes that URL. We don't want other users listening to their profile to be stuck with an invalid URL that fails on refresh, sharing, or with a click on the profile link. Handling the URL change took a whopping six lines of code in our Profile LiveView! def handle_info({MediaLibrary, %PublicProfileUpdated{} = update}, socket) do {:noreply, socket |> assign(profile: update.profile) |> push_patch(to: profile_path(update.profile))} end We are already subscribed to profile notifications, so we just have to handle the PublicProfileUpdated event, update the template profile state, and then push_patch to the client to trigger a pushState browser URL change. For a traditional application, this kind of feature would take standing up WebSocket connections, ad-hoc HTTP protocols, polling the server for changes, and other complexities. Let's see it in action: Concurrent Upload Processing LiveView uploads are handled over the existing WebSocket connection, providing interactive file uploads with file progress out-of-the-box - with no user-land JavaScript. It also allows other neat features like processing the file on the server before writing it to its final location. You might be thinking handling files on your server is sooo mid 2000's, but hear me out! Servers can have volumes... and you can like write files to them! Think about it. There aren't any Lambdas to wire up (and pay for per invocation), no webhooks to wire up, and no external message queues to configure, because it all happens over the existing LiveView connection. Compared to a typical cloud upload solution, we can cut out probably a few paid products and just as many failure modes. LiveView also guarantees the temporary uploaded file is on the same load-balanced instance of the LiveView processing the page. There's no external state to jump around between servers. So you write regular code that takes the file, post-processes or verifies the bits on disk, then writes it to its final location - all the while reporting to the UI the progress of each step. And we do need to extract data out of those binary blobs one way or another, because before we write that MP3 to storage, we want to know that (a) it's not a malicious file masquerading as an MP3 and (b) it's less than 20 minutes long. So the user drags and drops a handful of MP3s into the app, we upload them concurrently over the WebSocket connection, then we concurrently parse the binary MP3 data in a temporary file to verify it's a valid MP3 of acceptable duration. Once complete, we show the calculated duration on the UI and the user can save their playlist. An aside: It turns out calculating MP3 duration from file content is actually a pain. MP3s can contain ID3 tag metadata about the file, but answering the simple question of "how long is this song?" is surprisingly difficult. To properly calculate the duration of an MP3, you must walk all the frames and take bitrate encodings into consideration. There's a great write-up here on what's involved with step-by-step Elixir code to make it happen. Components LiveView recently shipped a new HEEx template engine that supports React-style template syntax with function components. Function components are reusable functions that encapsulate a bit of markup to be used throughout your UI. Think dropdowns, modals, tables, etc. We have a post all about function components, if you want to dive deeper. For example, one of our components is a TailwindUI dropdown, which looks like this: A Tailwind UI dropdown under Chris McCord's name, username, and photo, with options "View Profile," "Settings," and "Sign out" And this is what it looks like in code to use anywhere you'd like a dropdown: ~H""" <.dropdown id={@id}> <:img src={@current_user.avatar_url}/> <:title><%= @current_user.name %> <:subtitle>@<%= @current_user.username %> <:link navigate={profile_path(@current_user)}>View Profile <:link navigate={Routes.settings_path(Endpoint, :edit)}>Settings <:link href={Routes.session_path(Endpoint, :sign_out)} method={:delete}> Sign out """ Along with function components, LiveView includes slots, which allows a component to specify a named area of the component where arbitrary content can be placed by the caller. The actual dropdown component is just a simple function that encapsulates all the markup and classes of our Tailwind dropdown: def dropdown(assigns) do assigns = assigns |> assign_new(:img, fn -> nil end) |> assign_new(:title, fn -> nil end) |> assign_new(:subtitle, fn -> nil end) ~H"""