Skip to content

Commit b8ffcaa

Browse files
committed
feat: audio player support
1 parent d8b17cf commit b8ffcaa

16 files changed

Lines changed: 1167 additions & 1 deletion

lib/ekko.ex

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,7 @@ defmodule Ekko do
107107
defp run_lifecycle(skill, ekko, inner) do
108108
with {:ok, ekko} <- skill.before_request(ekko),
109109
{:ok, response} <- call_handler(skill, inner, ekko),
110+
{:ok, response} <- validate_response(inner, response),
110111
{:ok, response} <- skill.after_request(ekko, response) do
111112
{:ok, merge_session_attributes(response, ekko)}
112113
else
@@ -125,6 +126,27 @@ defmodule Ekko do
125126

126127
defp merge_session_attributes(response, _ekko), do: response
127128

129+
@audio_player_forbidden_fields ["outputSpeech", "card", "reprompt"]
130+
131+
defp validate_response(%Request.AudioPlayer{}, response) do
132+
check_audio_response(response)
133+
end
134+
135+
defp validate_response(%Request.PlaybackController{}, response) do
136+
check_audio_response(response)
137+
end
138+
139+
defp validate_response(_inner, response), do: {:ok, response}
140+
141+
defp check_audio_response(%{"response" => inner} = response) do
142+
case Enum.find(@audio_player_forbidden_fields, &Map.has_key?(inner, &1)) do
143+
nil -> {:ok, response}
144+
field -> {:error, {:invalid_audio_player_response, field}}
145+
end
146+
end
147+
148+
defp check_audio_response(response), do: {:ok, response}
149+
128150
# ── Speech ──────────────────────────────────────────────────────────────────
129151

130152
@doc """

lib/ekko/application.ex

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,8 @@ defmodule Ekko.Application do
77
def start(_type, _args) do
88
children = [
99
{Finch, name: Ekko.Finch},
10-
Ekko.Crypto.CertCache
10+
Ekko.Crypto.CertCache,
11+
Ekko.AudioPlayer.PlaylistStore.ETS
1112
]
1213

1314
opts = [strategy: :one_for_one, name: Ekko.Supervisor]

lib/ekko/audio_player.ex

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
defmodule Ekko.AudioPlayer do
2+
@moduledoc """
3+
High-level helpers for building AudioPlayer responses from a
4+
`Ekko.AudioPlayer.Playlist`.
5+
6+
These wrap the low-level directive builders in `Ekko` and translate
7+
`Playlist.track()` maps into the args those builders expect.
8+
9+
## Store operations
10+
11+
`load/1`, `save/2`, and `clear/1` delegate to the configured
12+
`Ekko.AudioPlayer.PlaylistStore` implementation:
13+
14+
config :ekko, playlist_store: Ekko.AudioPlayer.PlaylistStore.ETS
15+
16+
## Directive helpers
17+
18+
`play_current/2`, `play_next/2`, `play_previous/2`, `enqueue_next/2`,
19+
`stop_playback/1`, and `clear_and_play/2` pipe directives onto an
20+
`%Ekko{}` context, ready to be passed to `Ekko.build/1`.
21+
"""
22+
23+
alias Ekko.AudioPlayer.Playlist
24+
25+
# ── Store operations ─────────────────────────────────────────────────────
26+
27+
@doc "Loads the playlist for `user_id` from the configured store."
28+
@spec load(String.t()) :: {:ok, Playlist.t() | nil} | {:error, term()}
29+
def load(user_id) when is_binary(user_id), do: store().get(user_id)
30+
31+
@doc "Saves the playlist for `user_id` to the configured store."
32+
@spec save(String.t(), Playlist.t()) :: :ok | {:error, term()}
33+
def save(user_id, %Playlist{} = playlist), do: store().put(user_id, playlist)
34+
35+
@doc "Deletes the playlist for `user_id` from the configured store."
36+
@spec clear(String.t()) :: :ok | {:error, term()}
37+
def clear(user_id) when is_binary(user_id), do: store().delete(user_id)
38+
39+
# ── Directive helpers ──────────────────────────────────────────────────
40+
41+
@doc """
42+
Emits an `AudioPlayer.Play` directive for the current track in the
43+
playlist with `:replace_all` behavior.
44+
"""
45+
@spec play_current(Ekko.t(), Playlist.t()) :: Ekko.t()
46+
def play_current(%Ekko{} = ekko, %Playlist{} = pl) do
47+
case Playlist.current(pl) do
48+
nil -> ekko
49+
track -> play_track(ekko, :replace_all, track, [])
50+
end
51+
end
52+
53+
@doc """
54+
Advances the playlist and emits `AudioPlayer.Play` with `:replace_all`.
55+
56+
Returns `{ekko, updated_playlist}` on success, or `:end_of_playlist`.
57+
"""
58+
@spec play_next(Ekko.t(), Playlist.t()) :: {Ekko.t(), Playlist.t()} | :end_of_playlist
59+
def play_next(%Ekko{} = ekko, %Playlist{} = pl) do
60+
case Playlist.next(pl) do
61+
{:ok, pl, track} -> {play_track(ekko, :replace_all, track, []), pl}
62+
:end_of_playlist -> :end_of_playlist
63+
end
64+
end
65+
66+
@doc """
67+
Moves the playlist backward and emits `AudioPlayer.Play` with `:replace_all`.
68+
69+
Returns `{ekko, updated_playlist}` on success, or `:start_of_playlist`.
70+
"""
71+
@spec play_previous(Ekko.t(), Playlist.t()) :: {Ekko.t(), Playlist.t()} | :start_of_playlist
72+
def play_previous(%Ekko{} = ekko, %Playlist{} = pl) do
73+
case Playlist.previous(pl) do
74+
{:ok, pl, track} -> {play_track(ekko, :replace_all, track, []), pl}
75+
:start_of_playlist -> :start_of_playlist
76+
end
77+
end
78+
79+
@doc """
80+
Peeks at the next track and emits `AudioPlayer.Play` with `:enqueue`,
81+
setting `expected_previous_token` to the current track's token.
82+
83+
This is the typical call inside a `PlaybackNearlyFinished` handler.
84+
Returns the updated `%Ekko{}`, or the unchanged `ekko` if there's no
85+
next track.
86+
"""
87+
@spec enqueue_next(Ekko.t(), Playlist.t()) :: Ekko.t()
88+
def enqueue_next(%Ekko{} = ekko, %Playlist{} = pl) do
89+
current = Playlist.current(pl)
90+
91+
case Playlist.next(pl) do
92+
{:ok, _pl, next_track} ->
93+
prev_token = if current, do: current.token
94+
play_track(ekko, :enqueue, next_track, expected_previous_token: prev_token)
95+
96+
:end_of_playlist ->
97+
ekko
98+
end
99+
end
100+
101+
@doc "Emits an `AudioPlayer.Stop` directive."
102+
@spec stop_playback(Ekko.t()) :: Ekko.t()
103+
def stop_playback(%Ekko{} = ekko), do: Ekko.add_audio_player_stop(ekko)
104+
105+
@doc """
106+
Clears the queue and immediately plays the current track.
107+
Equivalent to `AudioPlayer.ClearQueue` with `:clear_all` followed by
108+
`AudioPlayer.Play` with `:replace_all`.
109+
"""
110+
@spec clear_and_play(Ekko.t(), Playlist.t()) :: Ekko.t()
111+
def clear_and_play(%Ekko{} = ekko, %Playlist{} = pl) do
112+
ekko
113+
|> Ekko.add_audio_player_clear_queue(:clear_all)
114+
|> play_current(pl)
115+
end
116+
117+
# ── Private ────────────────────────────────────────────────────────────
118+
119+
defp play_track(ekko, behavior, track, opts) do
120+
metadata =
121+
case {Map.get(track, :title), Map.get(track, :subtitle), Map.get(track, :art_url)} do
122+
{nil, nil, nil} -> []
123+
_ -> [metadata: %{title: track[:title], subtitle: track[:subtitle], art: track[:art_url]}]
124+
end
125+
126+
Ekko.add_audio_player_play(
127+
ekko,
128+
behavior,
129+
track.url,
130+
track.token,
131+
0,
132+
opts ++ metadata
133+
)
134+
end
135+
136+
defp store do
137+
Application.get_env(:ekko, :playlist_store, Ekko.AudioPlayer.PlaylistStore.ETS)
138+
end
139+
end

0 commit comments

Comments
 (0)