Mix.install(
[
{:mdex, "~> 0.10"},
{:lumis, "~> 0.1"},
{:phoenix_playground, "~> 0.1"}
],
config: [mdex_native: [syntax_highlighter: :lumis]]
)
defmodule DemoLayout do
use Phoenix.Component
def render("root.html", assigns) do
~H"""
MDEx Streaming Demo
<%= @inner_content %>
"""
end
end
defmodule RenderPanel do
use Phoenix.LiveComponent
def render(assigns) do
~H"""
Rendered Output
<%= if @html == "" do %>
Rendered output will appear here
<% else %>
{Phoenix.HTML.raw(@html)}
<% end %>
"""
end
end
defmodule ChunksPanel do
use Phoenix.LiveComponent
def render(assigns) do
~H"""
Markdown Chunks
<%= if @chunks == [] do %>
No chunks yet
<% else %>
<%= for chunk <- @chunks do %>
<%= String.slice(inspect(chunk), 1..-2//1) %>
<% end %>
<% end %>
"""
end
end
defmodule StreamingDemo do
use Phoenix.LiveView
@mdex_options [
extension: [
alerts: true,
autolink: true,
footnotes: true,
shortcodes: true,
strikethrough: true,
table: true,
tagfilter: true,
tasklist: true,
math_dollars: true
],
parse: [
relaxed_autolinks: true,
relaxed_tasklist_matching: true
],
render: [
github_pre_lang: true,
full_info_string: true,
unsafe: true
],
syntax_highlight: [formatter: {:html_inline, theme: "github_light"}],
streaming: true
]
def mount(_params, _session, socket) do
{:ok,
assign(socket,
document: MDEx.new(@mdex_options),
chunks: [],
html: "",
streaming: false,
speed: 80,
current_chunk: 0,
total_chunks: 0,
stream_ref: nil
)}
end
def render(assigns) do
~H"""
MDEx Streaming
<%= @speed %>ms
<%= if @total_chunks == 0 do %>
Waiting
- / -
<% else %>
Streaming
<%= @current_chunk %> / <%= @total_chunks %>
<% end %>
<.live_component module={RenderPanel} id="render-panel" html={@html} />
<.live_component module={ChunksPanel} id="chunks-panel" chunks={Enum.take(@chunks, @current_chunk)} />
"""
end
def handle_event("start_demo", _params, socket) do
content = """
# Streaming Music App with Phoenix LiveView
:rocket: Launch a collaborative listening room with real-time diffs driven by `Phoenix LiveView`.
## Why LiveView Fits
Phoenix LiveView keeps the render pipeline on the server, letting state changes push efficient patches into the session. This means listeners enjoy responsive updates without custom JavaScript, yet you retain declarative Elixir code.
### Experience Goals
- :notes: Seamless streaming controls
- :headphones: Shared queue awareness
- :sparkles: Animated feedback without page reload
- :musical_keyboard: Keyboard shortcuts for power users
## Task Board
- [x] Initialize project scaffolding
- [ ] Wire up LiveView routes
- [x] Create router
- [ ] Add authentication
- [ ] Connect to the audio backend
- [ ] Style the listening room
- [ ] Publish real-time metrics dashboard
## Architecture Snapshot
| Layer | Responsibility | LiveView Hook |
| --- | --- | --- |
| LiveView | Stateful UI process | mount / handle_event |
| PubSub | Broadcast track events | Phoenix.PubSub |
| Context | Business logic | StreamMusic.Library |
| Presence | Listener roster tracking | Phoenix.Presence |
| Data | Persistent playlists | Ecto schemas |
## Setup Command
```bash
mix phx.new stream_music --live
cd stream_music
mix deps.get
```
## LiveView Outline
```elixir
defmodule StreamMusicWeb.PlayerLive do
use StreamMusicWeb, :live_view
alias Phoenix.PubSub
@topic "stream_music:queue"
def mount(_params, _session, socket) do
if connected?(socket) do
Phoenix.PubSub.subscribe(StreamMusic.PubSub, @topic)
end
{:ok, assign(socket, playlist: [], now_playing: nil, search: "", volume: 60, listeners: %{})}
end
def handle_event("search", %{"query" => query}, socket) do
{:noreply, assign(socket, search: query)}
end
def handle_event("queue", %{"track" => track}, socket) do
Phoenix.PubSub.broadcast(StreamMusic.PubSub, @topic, {:queue, track})
{:noreply, update(socket, :playlist, fn list -> list ++ [track] end)}
end
def handle_event("play", %{"track" => track}, socket) do
Phoenix.PubSub.broadcast(StreamMusic.PubSub, @topic, {:play, track})
{:noreply, assign(socket, now_playing: track)}
end
def handle_info({:queue, track}, socket) do
{:noreply, update(socket, :playlist, fn list -> list ++ [track] end)}
end
def handle_info({:play, track}, socket) do
{:noreply, assign(socket, now_playing: track)}
end
end
```
### Progressive Streaming
LiveViews start with a static render before upgrading to the persistent connection, so first meaningful paint stays fast while future updates travel over a single channel.
### Performance Metrics
The connection latency can be modeled as:
$$
L = \\frac{RTT}{2} + P_{server}
$$
Where $L$ is total latency, $RTT$ is round-trip time, and $P_{server}$ is server processing time.
For optimal user experience, aim for $L < 100ms$ to maintain the illusion of instantaneous updates.
## Interaction Flow
1. Visitor opens the lobby and receives the rendered `PlayerLive`.
2. `mount/3` seeds assigns with playlist snapshots.
3. Track searches call `handle_event/3` to refine results.
4. Queue updates broadcast through PubSub and hydrate everyone.
5. Presence diff pushes listener join or leave events.
### Playlist Signals
- **Now Playing**: highlight the active track and waveform
- **Queue**: show pending entries with avatars
- **History**: list completed tracks for replay fans
### Commands for Library Context
Run the generator to scaffold data boundaries:
- `mix phx.gen.live Library Track tracks title:string artist:string duration:integer source_url:string`
- `mix phx.gen.schema Library.Room rooms slug:string theme:string description:text`
### Queue Feedback
| Event | LiveView Callback | Outcome |
| --- | --- | --- |
| :mag: Search query | `handle_event "search"` | Update suggestions |
| :heavy_plus_sign: Queue track | `handle_event "queue"` | Append playlist |
| :arrow_forward: Play track | `handle_event "play"` | Change headline state |
| :busts_in_silhouette: Presence diff | `handle_info {:presence_diff, diff}` | Refresh listener list |
| :checkered_flag: Track finished | `handle_info {:playback_done, track}` | Rotate playlist |
### Listener Journey
- Enter lobby and see :headphones: welcome banner
- Use instant search to find a favorite song
- Add the track to the collaborative queue
- Watch :rocket: transitions as the now playing card updates
- React with inline emoji to celebrate the vibe
- Share with friends via or https://streammusic.app

## Real-Time Considerations
> **Important**: Keep these best practices in mind when building real-time features.
>
> Performance is critical for user experience in collaborative apps.
- Keep assigns minimal to avoid large diffs
- Stream lists with `Phoenix.LiveView.stream/4` for scalable queues
- Push events with `push_event/3` for waveform animations
- Use `temporary_assigns` to discard transient payloads
- ~~Avoid polling~~ Use LiveView for real-time updates
- Balance updates between server broadcasts and client hooks
### Client Integration
```javascript
// Subscribe to live updates
const socket = new Socket("/socket", {params: {token: userToken}})
socket.connect()
const channel = socket.channel("room:lobby", {})
channel.join()
.receive("ok", resp => { console.log("Joined successfully", resp) })
.receive("error", resp => { console.log("Unable to join", resp) })
channel.on("new_track", payload => {
updateNowPlaying(payload.track)
})
```
### Deployment Notes
:package: Infrastructure Requirements
- Configure [CDN edge caching](https://docs.example.com/cdn) for artwork
- Enable `live_session` routes for authentication
- Tune WebSocket pool size for expected rooms
- Leverage clustered nodes for resilient PubSub
💡 Pro Tip: Start with a single node and scale horizontally as your user base grows.
Monitor metrics at https://metrics.streammusic.app
## Next Steps
1. Finalize UI polish in Tailwind components
2. Integrate payment tiers for premium rooms
3. Add offline fallbacks when connection drops
4. Extend analytics pipeline for retention insights
## Celebration
Wrap the launch with :tada: playlists and a :musical_note: release party!
---
*Built with :purple_heart: using [Elixir](https://elixir-lang.org) and [MDEx](https://hex.pm/packages/mdex)*
"""
chunks = chunk_for_ai_streaming(content)
simulate_streaming(socket, chunks)
end
def handle_event("clear", _params, socket) do
socket
|> assign(
document: MDEx.new(@mdex_options),
chunks: [],
html: "",
streaming: false,
current_chunk: 0,
total_chunks: 0,
stream_ref: nil
)
|> then(&{:noreply, &1})
end
def handle_event("update_speed", %{"speed" => speed}, socket) do
actual_speed = 1001 - String.to_integer(speed)
{:noreply, assign(socket, :speed, actual_speed)}
end
def handle_info({:stream_chunk, ref, chunk}, %{assigns: %{stream_ref: ref}} = socket) do
document = Enum.into([chunk], socket.assigns.document)
html = html_from(document)
socket =
socket
|> update(:chunks, fn chunks -> chunks ++ [chunk] end)
|> assign(:document, document)
|> assign(:html, html)
|> update(:current_chunk, &(&1 + 1))
{:noreply, socket}
end
def handle_info({:stream_chunk, _ref, _chunk}, socket), do: {:noreply, socket}
def handle_info({:streaming_complete, ref}, %{assigns: %{stream_ref: ref}} = socket) do
{:noreply, assign(socket, :streaming, false)}
end
def handle_info({:streaming_complete, _ref}, socket), do: {:noreply, socket}
defp simulate_streaming(socket, chunks) do
speed = socket.assigns.speed
total_chunks = Enum.count(chunks)
stream_ref = make_ref()
parent = self()
Task.start(fn ->
chunks
|> Stream.each(fn chunk ->
Process.sleep(speed)
send(parent, {:stream_chunk, stream_ref, chunk})
end)
|> Stream.run()
send(parent, {:streaming_complete, stream_ref})
end)
updated_socket =
assign(socket, %{
document: MDEx.new(@mdex_options),
chunks: [],
html: "",
streaming: true,
speed: speed,
current_chunk: 0,
total_chunks: total_chunks,
stream_ref: stream_ref
})
{:noreply, updated_socket}
end
defp html_from(%MDEx.Document{} = document), do: MDEx.to_html!(document, @mdex_options)
defp chunk_for_ai_streaming(text) do
text
|> String.graphemes()
|> do_random_chunk([])
end
defp do_random_chunk([], acc), do: Enum.reverse(acc)
defp do_random_chunk(graphemes, acc) do
chunk_size = Enum.random(3..20)
{chunk, rest} = Enum.split(graphemes, chunk_size)
case chunk do
[] -> Enum.reverse(acc)
_ -> do_random_chunk(rest, [Enum.join(chunk) | acc])
end
end
end
defmodule DemoRouter do
use Phoenix.Router
import Phoenix.LiveView.Router
pipeline :browser do
plug(:accepts, ["html"])
plug(:fetch_session)
plug(:put_root_layout, html: {DemoLayout, :root})
plug(:put_secure_browser_headers)
end
scope "/" do
pipe_through(:browser)
live("/", StreamingDemo)
end
end
PhoenixPlayground.start(plug: DemoRouter, open_browser: true)