Implement searching for music via Spotify
Diff
config-example.toml | 3 +++
shalom/src/config.rs | 11 +++++++++--
shalom/src/hass_client.rs | 28 ++++++++++++++++++++++++++++
shalom/src/main.rs | 21 +++++++++++++++++----
shalom/src/oracle.rs | 18 +++++++++++++++---
shalom/src/subscriptions.rs | 66 ++++++++++++++++++++++++++++++++++++++++++------------------------
shalom/src/pages/room.rs | 5 +++--
shalom/src/pages/room/listen.rs | 383 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------
shalom/src/pages/room/listen/search.rs | 46 +++++++++++++++++++++++++++++++++++++---------
9 files changed, 456 insertions(+), 125 deletions(-)
@@ -1,3 +1,6 @@
[home-assistant]
uri = "http://192.168.1.100/"
token = "abcdef"
[spotify]
token = "abcdef"
@@ -8,13 +8,20 @@
pub const LAST_FM_SHARED_SECRET: &str = "420fdb301e6b4a62a888bf51def71670";
pub const FANART_PROJECT_KEY: &str = "df5eb171c6e0e49122ad59830cdf789f";
#[derive(Deserialize)]
#[derive(Deserialize, Clone, Debug)]
#[serde(rename_all = "kebab-case")]
pub struct Config {
pub home_assistant: HomeAssistantConfig,
pub spotify: SpotifyConfig,
}
#[derive(Deserialize)]
#[derive(Deserialize, Clone, Debug)]
#[serde(rename_all = "kebab-case")]
pub struct SpotifyConfig {
pub token: String,
}
#[derive(Deserialize, Clone, Debug)]
#[serde(rename_all = "kebab-case")]
pub struct HomeAssistantConfig {
pub uri: String,
@@ -295,6 +295,7 @@
MediaSeek(CallServiceRequestMediaPlayerMediaSeek),
ShuffleSet(CallServiceRequestMediaPlayerShuffleSet),
RepeatSet(CallServiceRequestMediaPlayerRepeatSet),
PlayMedia(CallServiceRequestMediaPlayerPlayMedia),
MediaPlay,
MediaPause,
MediaNextTrack,
@@ -326,6 +327,33 @@
#[derive(Serialize)]
pub struct CallServiceRequestMediaPlayerRepeatSet {
pub repeat: MediaPlayerRepeat,
}
#[derive(Serialize)]
pub struct CallServiceRequestMediaPlayerPlayMedia {
pub media_content_id: String,
pub media_content_type: CallServiceRequestMediaPlayerPlayMediaType,
pub enqueue: CallServiceRequestMediaPlayerPlayMediaEnqueue,
}
#[derive(Copy, Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum CallServiceRequestMediaPlayerPlayMediaType {
Music,
Tvshow,
Video,
Episode,
Channel,
Playlist,
}
#[derive(Copy, Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum CallServiceRequestMediaPlayerPlayMediaEnqueue {
Add,
Next,
Play,
Replace,
}
#[derive(Copy, Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
@@ -26,6 +26,7 @@
oracle: Option<Arc<Oracle>>,
home_room: Option<&'static str>,
theme: Theme,
config: Option<Arc<Config>>,
}
impl Shalom {
@@ -40,6 +41,7 @@
ActivePage::Room(pages::room::Room::new(
room,
self.oracle.as_ref().unwrap().clone(),
self.config.as_ref().unwrap().clone(),
))
}
@@ -160,6 +162,14 @@
Message::UpdateLightResult,
)
}
pages::room::listen::Event::PlayTrack(id, uri) => {
let oracle = self.oracle.as_ref().unwrap().clone();
Command::perform(
async move { oracle.speaker(id).play_track(uri).await },
Message::PlayTrackResult,
)
}
}
}
}
@@ -177,6 +187,7 @@
oracle: None,
home_room: Some("living_room"),
theme: Theme::default(),
config: None,
};
@@ -186,8 +197,8 @@
let command = Command::perform(
async {
let config = load_config().await;
let client = hass_client::create(config.home_assistant).await;
Oracle::new(client.clone()).await
let client = hass_client::create(config.home_assistant.clone()).await;
(Oracle::new(client.clone()).await, config)
},
Message::Loaded,
);
@@ -203,8 +214,9 @@
fn update(&mut self, message: Self::Message) -> Command<Self::Message> {
#[allow(clippy::single_match)]
match (message, &mut self.page, &mut self.context_menu) {
(Message::Loaded(oracle), _, _) => {
(Message::Loaded((oracle, config)), _, _) => {
self.oracle = Some(oracle);
self.config = Some(Arc::new(config));
self.page = self.build_home_route();
Command::none()
}
@@ -288,7 +300,7 @@
#[derive(Debug, Clone)]
pub enum Message {
Loaded(Arc<Oracle>),
Loaded((Arc<Oracle>, Config)),
CloseContextMenu,
OpenOmniPage,
OpenHomePage,
@@ -296,6 +308,7 @@
RoomEvent(pages::room::Message),
LightControlMenu(context_menus::light_control::Message),
UpdateLightResult(()),
PlayTrackResult(()),
}
#[derive(Debug)]
@@ -30,9 +30,10 @@
},
CallServiceRequestData, CallServiceRequestLight, CallServiceRequestLightTurnOn,
CallServiceRequestMediaPlayer, CallServiceRequestMediaPlayerMediaSeek,
CallServiceRequestMediaPlayerRepeatSet, CallServiceRequestMediaPlayerShuffleSet,
CallServiceRequestMediaPlayerVolumeMute, CallServiceRequestMediaPlayerVolumeSet, Event,
HassRequestKind, MediaPlayerRepeat,
CallServiceRequestMediaPlayerPlayMedia, CallServiceRequestMediaPlayerPlayMediaEnqueue,
CallServiceRequestMediaPlayerPlayMediaType, CallServiceRequestMediaPlayerRepeatSet,
CallServiceRequestMediaPlayerShuffleSet, CallServiceRequestMediaPlayerVolumeMute,
CallServiceRequestMediaPlayerVolumeSet, Event, HassRequestKind, MediaPlayerRepeat,
},
widgets::colour_picker::clamp_to_u8,
};
@@ -459,6 +460,17 @@
pub async fn previous(&self) {
self.call(CallServiceRequestMediaPlayer::MediaPreviousTrack)
.await;
}
pub async fn play_track(&self, uri: String) {
self.call(CallServiceRequestMediaPlayer::PlayMedia(
CallServiceRequestMediaPlayerPlayMedia {
media_content_id: uri,
media_content_type: CallServiceRequestMediaPlayerPlayMediaType::Music,
enqueue: CallServiceRequestMediaPlayerPlayMediaEnqueue::Play,
},
))
.await;
}
}
@@ -1,9 +1,10 @@
use std::num::NonZeroUsize;
use iced::{futures::stream, subscription, widget::image, Subscription};
use lru::LruCache;
use once_cell::sync::Lazy;
use parking_lot::Mutex;
use reqwest::IntoUrl;
use url::Url;
use crate::config::FANART_PROJECT_KEY;
@@ -28,41 +29,50 @@
post_process: fn(::image::RgbaImage) -> ::image::RgbaImage,
resp: impl FnOnce(image::Handle) -> M + Send + 'static,
) -> Subscription<M> {
static CACHE: Lazy<Mutex<LruCache<Url, image::Handle>>> =
Lazy::new(|| Mutex::new(LruCache::new(NonZeroUsize::new(10).unwrap())));
subscription::run_with_id(
url.to_string(),
stream::once(async move {
eprintln!("{url} dl");
if let Some(handle) = CACHE.lock().get(&url) {
return (resp)(handle.clone());
}
let bytes = reqwest::get(url.clone())
.await
.unwrap()
.bytes()
.await
.unwrap();
let handle = tokio::task::spawn_blocking(move || {
eprintln!("parsing image");
let img = ::image::load_from_memory(&bytes).unwrap();
eprintln!("post processing");
let data = post_process(img.into_rgba8());
let (h, w) = data.dimensions();
image::Handle::from_pixels(h, w, data.into_raw())
})
.await
.unwrap();
CACHE.lock().push(url.clone(), handle.clone());
(resp)(handle)
(resp)(load_image(url.clone(), post_process).await)
}),
)
}
pub async fn load_image<T: IntoUrl>(
url: T,
post_process: fn(::image::RgbaImage) -> ::image::RgbaImage,
) -> image::Handle {
static CACHE: Lazy<Mutex<LruCache<Url, image::Handle>>> =
Lazy::new(|| Mutex::new(LruCache::new(NonZeroUsize::new(50).unwrap())));
let url = url.into_url().unwrap();
if let Some(handle) = CACHE.lock().get(&url) {
return handle.clone();
}
let bytes = reqwest::get(url.clone())
.await
.unwrap()
.bytes()
.await
.unwrap();
let handle = tokio::task::spawn_blocking(move || {
eprintln!("parsing image");
let img = ::image::load_from_memory(&bytes).unwrap();
eprintln!("post processing");
let data = post_process(img.into_rgba8());
let (h, w) = data.dimensions();
image::Handle::from_pixels(h, w, data.into_raw())
})
.await
.unwrap();
CACHE.lock().push(url.clone(), handle.clone());
handle
}
pub fn find_musicbrainz_artist<M: 'static>(
@@ -12,6 +12,7 @@
};
use crate::{
config::Config,
oracle::Oracle,
subscriptions::MaybePendingImage,
widgets::{
@@ -30,12 +31,12 @@
}
impl Room {
pub fn new(id: &'static str, oracle: Arc<Oracle>) -> Self {
pub fn new(id: &'static str, oracle: Arc<Oracle>, config: Arc<Config>) -> Self {
let room = oracle.room(id).clone();
Self {
id,
listen: listen::Listen::new(oracle.clone(), &room),
listen: listen::Listen::new(oracle.clone(), &room, config),
lights: lights::Lights::new(oracle, &room),
room,
current_page: Page::Listen,
@@ -1,22 +1,28 @@
mod search;
use std::{convert::identity, sync::Arc, time::Duration};
use std::{borrow::Cow, convert::identity, sync::Arc, time::Duration};
use iced::{
futures::StreamExt,
futures::{future, stream, stream::FuturesUnordered, FutureExt, StreamExt},
subscription,
widget::{container, image::Handle, lazy, Column, Text},
Element, Length, Renderer, Subscription, Theme,
};
use itertools::Itertools;
use serde::Deserialize;
use url::Url;
use yoke::{Yoke, Yokeable};
use crate::{
config::Config,
hass_client::MediaPlayerRepeat,
magic::header_search::header_search,
oracle::{MediaPlayerSpeaker, MediaPlayerSpeakerState, Oracle, Room},
pages::room::listen::search::SearchResult,
subscriptions::{download_image, find_fanart_urls, find_musicbrainz_artist, MaybePendingImage},
theme::{darken_image, trim_transparent_padding, Image},
subscriptions::{
download_image, find_fanart_urls, find_musicbrainz_artist, load_image, MaybePendingImage,
},
theme::{darken_image, trim_transparent_padding},
widgets,
};
@@ -29,12 +35,12 @@
musicbrainz_artist_id: Option<String>,
pub background: Option<MaybePendingImage>,
artist_logo: Option<MaybePendingImage>,
search_query: String,
search_open: bool,
search: SearchState,
config: Arc<Config>,
}
impl Listen {
pub fn new(oracle: Arc<Oracle>, room: &Room) -> Self {
pub fn new(oracle: Arc<Oracle>, room: &Room, config: Arc<Config>) -> Self {
let speaker = room.speaker(&oracle);
Self {
@@ -45,27 +51,31 @@
musicbrainz_artist_id: None,
background: None,
artist_logo: None,
search_query: String::new(),
search_open: false,
search: SearchState::Closed,
config,
}
}
pub fn header_magic(&self, text: Text<'static>) -> Element<'static, Message> {
lazy(
(self.search_open, self.search_query.clone()),
move |(open, query)| {
header_search(
Message::OnSearchTerm,
Message::OnSearchVisibleChange,
*open,
query,
text.clone(),
)
},
)
lazy(self.search.clone(), move |search| {
let (open, query) = if let Some(v) = search.search() {
(true, v)
} else {
(false, "")
};
header_search(
Message::OnSearchTerm,
Message::OnSearchVisibleChange,
open,
query,
text.clone(),
)
})
.into()
}
#[allow(clippy::too_many_lines)]
pub fn update(&mut self, event: Message) -> Option<Event> {
match event {
Message::AlbumArtImageLoaded(handle) => {
@@ -162,68 +172,62 @@
None
}
Message::OnSearchTerm(v) => {
self.search_query = v;
self.search = self.search.open(v);
None
}
Message::OnSearchVisibleChange(v) => {
self.search_open = v;
self.search_query = String::new();
self.search = if v {
SearchState::Open {
search: String::new(),
results: vec![],
needs_result: false,
waiting_for_result: false,
}
} else {
SearchState::Closed
};
None
}
Message::SpotifySearchResult(res) => {
if let SearchState::Open {
results,
needs_result,
..
} = &mut self.search
{
if *needs_result {
results.clear();
*needs_result = false;
}
results.push(res);
}
None
}
Message::SpotifySearchResultDone => {
if let SearchState::Open {
waiting_for_result, ..
} = &mut self.search
{
*waiting_for_result = false;
}
None
}
Message::OnPlayTrack(uri) => Some(Event::PlayTrack(self.speaker.as_ref()?.0, uri)),
}
}
pub fn view(&self, style: &Theme) -> Element<'_, Message, Renderer> {
if self.search_open && !self.search_query.is_empty() {
let results = vec![
SearchResult::album(Image::AlbumArtTest.into(), "Some Album".to_string()),
SearchResult::track(
Image::AlbumArtTest.into(),
"Some Track".to_string(),
"Some Artist".to_string(),
),
SearchResult::playlist(Image::AlbumArtTest.into(), "Some Playlist".to_string()),
SearchResult::album(Image::AlbumArtTest.into(), "Some Album".to_string()),
SearchResult::track(
Image::AlbumArtTest.into(),
"Some Track".to_string(),
"Some Artist".to_string(),
),
SearchResult::playlist(Image::AlbumArtTest.into(), "Some Playlist".to_string()),
SearchResult::album(Image::AlbumArtTest.into(), "Some Album".to_string()),
SearchResult::track(
Image::AlbumArtTest.into(),
"Some Track".to_string(),
"Some Artist".to_string(),
),
SearchResult::playlist(Image::AlbumArtTest.into(), "Some Playlist".to_string()),
SearchResult::album(Image::AlbumArtTest.into(), "Some Album".to_string()),
SearchResult::track(
Image::AlbumArtTest.into(),
"Some Track".to_string(),
"Some Artist".to_string(),
),
SearchResult::playlist(Image::AlbumArtTest.into(), "Some Playlist".to_string()),
SearchResult::album(Image::AlbumArtTest.into(), "Some Album".to_string()),
SearchResult::track(
Image::AlbumArtTest.into(),
"Some Track".to_string(),
"Some Artist".to_string(),
),
SearchResult::playlist(Image::AlbumArtTest.into(), "Some Playlist".to_string()),
SearchResult::album(Image::AlbumArtTest.into(), "Some Album".to_string()),
SearchResult::track(
Image::AlbumArtTest.into(),
"Some Track".to_string(),
"Some Artist".to_string(),
),
SearchResult::playlist(Image::AlbumArtTest.into(), "Some Playlist".to_string()),
];
container(search::search(style.clone(), results))
.padding([0, 40, 40, 40])
.width(Length::Fill)
.into()
if self.search.is_open() {
container(
search::search(style.clone(), self.search.results())
.on_track_press(Message::OnPlayTrack),
)
.padding([0, 40, 40, 40])
.width(Length::Fill)
.into()
} else if let Some((_, speaker)) = self.speaker.clone() {
container(
widgets::media_player::media_player(speaker, self.album_art_image.clone())
@@ -309,6 +313,17 @@
.subscribe_id(speaker_id)
.map(|()| Message::UpdateSpeaker),
)
} else {
Subscription::none()
};
let spotify_result = if let SearchState::Open {
search,
waiting_for_result: true,
..
} = &self.search
{
search_spotify(search.to_string(), &self.config.spotify.token)
} else {
Subscription::none()
};
@@ -320,11 +335,60 @@
background_subscription,
logo_subscription,
fanart_subscription,
spotify_result,
])
}
}
#[derive(Debug, Hash, Clone)]
pub enum SearchState {
Open {
search: String,
results: Vec<SearchResult>,
needs_result: bool,
waiting_for_result: bool,
},
Closed,
}
impl SearchState {
pub fn is_open(&self) -> bool {
matches!(self, Self::Open { search, .. } if !search.is_empty())
}
pub fn results(&self) -> &[SearchResult] {
match self {
Self::Open { results, .. } => results.as_slice(),
Self::Closed => &[],
}
}
pub fn search(&self) -> Option<&str> {
match self {
SearchState::Open { search, .. } => Some(search),
SearchState::Closed => None,
}
}
pub fn open(&self, search: String) -> SearchState {
match self {
Self::Open { results, .. } => Self::Open {
search,
results: results.clone(),
needs_result: true,
waiting_for_result: true,
},
Self::Closed => Self::Open {
search,
results: vec![],
needs_result: true,
waiting_for_result: true,
},
}
}
}
#[derive(Copy, Clone)]
#[derive(Clone)]
pub enum Event {
SetSpeakerVolume(&'static str, f32),
SetSpeakerPosition(&'static str, Duration),
@@ -334,6 +398,7 @@
SetSpeakerRepeat(&'static str, MediaPlayerRepeat),
SpeakerNextTrack(&'static str),
SpeakerPreviousTrack(&'static str),
PlayTrack(&'static str, String),
}
#[derive(Clone, Debug)]
@@ -354,4 +419,172 @@
OnSpeakerPreviousTrack,
OnSearchTerm(String),
OnSearchVisibleChange(bool),
SpotifySearchResult(SearchResult),
SpotifySearchResultDone,
OnPlayTrack(String),
}
fn search_spotify(search: String, token: &str) -> Subscription<Message> {
let token = token.to_string();
subscription::run_with_id(
format!("search-{search}"),
stream::once(async move {
let mut url = Url::parse("https://api.spotify.com/v1/search").unwrap();
url.query_pairs_mut()
.append_pair("q", &search)
.append_pair("type", "album,artist,playlist,track")
.append_pair("market", "GB")
.append_pair("limit", "20");
let res = reqwest::Client::new()
.get(url)
.header("Authorization", format!("Bearer {token}"))
.send()
.await
.unwrap()
.text()
.await
.unwrap();
eprintln!("{}", std::str::from_utf8(res.as_ref()).unwrap());
Yoke::attach_to_cart(res, |s| serde_json::from_str(s).unwrap())
})
.flat_map(|res: Yoke<SpotifySearchResult<'static>, String>| {
let res = res.get();
let results = FuturesUnordered::new();
for track in &res.tracks.items {
let image_url = track.album.images[0].url.to_string();
let track_name = track.name.to_string();
let artist_name = track.artists.iter().map(|v| &v.name).join(", ");
let uri = track.uri.to_string();
results.push(
async move {
let image = load_image(image_url, identity).await;
SearchResult::track(image, track_name, artist_name, uri)
}
.boxed(),
);
}
for artist in &res.artists.items {
let image_url = artist.images[0].url.to_string();
let artist_name = artist.name.to_string();
let uri = artist.uri.to_string();
results.push(
async move {
let image = load_image(image_url, identity).await;
SearchResult::artist(image, artist_name, uri)
}
.boxed(),
);
}
for albums in &res.albums.items {
let image_url = albums.images[0].url.to_string();
let album_name = albums.name.to_string();
let uri = albums.uri.to_string();
results.push(
async move {
let image = load_image(image_url, identity).await;
SearchResult::album(image, album_name, uri)
}
.boxed(),
);
}
for playlist in &res.playlists.items {
let image_url = playlist.images[0].url.to_string();
let playlist_name = playlist.name.to_string();
let uri = playlist.uri.to_string();
results.push(
async move {
let image = load_image(image_url, identity).await;
SearchResult::playlist(image, playlist_name, uri)
}
.boxed(),
);
}
results.map(Message::SpotifySearchResult)
})
.chain(stream::once(future::ready(
Message::SpotifySearchResultDone,
))),
)
}
#[derive(Deserialize, Yokeable)]
pub struct SpotifySearchResult<'a> {
#[serde(borrow)]
tracks: SpotifySearchResultWrapper<SpotifyTrack<'a>>,
#[serde(borrow)]
artists: SpotifySearchResultWrapper<SpotifyArtist<'a>>,
#[serde(borrow)]
albums: SpotifySearchResultWrapper<SpotifyAlbum<'a>>,
#[serde(borrow)]
playlists: SpotifySearchResultWrapper<SpotifyPlaylist<'a>>,
}
#[derive(Deserialize)]
pub struct SpotifySearchResultWrapper<T> {
items: Vec<T>,
}
#[allow(dead_code)]
#[derive(Deserialize, Yokeable)]
pub struct SpotifyTrack<'a> {
#[serde(borrow)]
album: SpotifyAlbum<'a>,
#[serde(borrow)]
artists: Vec<SpotifyArtist<'a>>,
#[serde(borrow)]
name: Cow<'a, str>,
#[serde(borrow)]
uri: Cow<'a, str>,
}
#[derive(Deserialize, Yokeable)]
#[allow(dead_code)]
pub struct SpotifyAlbum<'a> {
#[serde(borrow)]
name: Cow<'a, str>,
#[serde(borrow, default)]
images: Vec<SpotifyImage<'a>>,
#[serde(borrow, default)]
artists: Vec<SpotifyArtist<'a>>,
#[serde(borrow)]
uri: Cow<'a, str>,
}
#[derive(Deserialize, Yokeable)]
pub struct SpotifyPlaylist<'a> {
#[serde(borrow)]
name: Cow<'a, str>,
#[serde(borrow, default)]
images: Vec<SpotifyImage<'a>>,
#[serde(borrow)]
uri: Cow<'a, str>,
}
#[derive(Deserialize)]
pub struct SpotifyArtist<'a> {
#[serde(borrow)]
name: Cow<'a, str>,
#[serde(borrow, default)]
images: Vec<SpotifyImage<'a>>,
#[serde(borrow)]
uri: Cow<'a, str>,
}
#[derive(Deserialize)]
pub struct SpotifyImage<'a> {
#[serde(borrow)]
url: Cow<'a, str>,
}
@@ -11,7 +11,7 @@
use crate::widgets::mouse_area::mouse_area;
pub fn search<M: Clone + 'static>(theme: Theme, results: Vec<SearchResult>) -> Search<M> {
pub fn search<M: Clone + 'static>(theme: Theme, results: &[SearchResult]) -> Search<'_, M> {
Search {
on_track_press: None,
theme,
@@ -19,13 +19,20 @@
}
}
pub struct Search<M> {
pub struct Search<'a, M> {
on_track_press: Option<fn(String) -> M>,
theme: Theme,
results: Vec<SearchResult>,
results: &'a [SearchResult],
}
impl<M: Clone + 'static> Component<M, Renderer> for Search<M> {
impl<M> Search<'_, M> {
pub fn on_track_press(mut self, f: fn(String) -> M) -> Self {
self.on_track_press = Some(f);
self
}
}
impl<M: Clone + 'static> Component<M, Renderer> for Search<'_, M> {
type State = ();
type Event = Event;
@@ -44,7 +51,7 @@
}
let track = mouse_area(search_item_container(result_card(result, &self.theme)))
.on_press(Event::OnTrackPress("hello world".to_string()));
.on_press(Event::OnTrackPress(result.uri.to_string()));
col = col.push(track);
}
@@ -53,13 +60,13 @@
}
}
impl<M: 'static + Clone> From<Search<M>> for Element<'static, M, Renderer> {
fn from(value: Search<M>) -> Self {
impl<'a, M: 'static + Clone> From<Search<'a, M>> for Element<'a, M, Renderer> {
fn from(value: Search<'a, M>) -> Self {
component(value)
}
}
#[derive(Clone)]
#[derive(Clone, Debug)]
pub enum Event {
OnTrackPress(String),
}
@@ -118,42 +125,58 @@
}
#[allow(clippy::module_name_repetitions)]
#[derive(Debug, Clone, Hash)]
pub struct SearchResult {
image: Handle,
title: String,
uri: String,
metadata: ResultMetadata,
}
impl SearchResult {
pub fn track(image: Handle, title: String, artist: String) -> Self {
pub fn track(image: Handle, title: String, artist: String, uri: String) -> Self {
Self {
image,
title,
uri,
metadata: ResultMetadata::Track(artist),
}
}
pub fn playlist(image: Handle, title: String) -> Self {
pub fn playlist(image: Handle, title: String, uri: String) -> Self {
Self {
image,
title,
uri,
metadata: ResultMetadata::Playlist,
}
}
pub fn artist(image: Handle, title: String, uri: String) -> Self {
Self {
image,
title,
uri,
metadata: ResultMetadata::Artist,
}
}
pub fn album(image: Handle, title: String) -> Self {
pub fn album(image: Handle, title: String, uri: String) -> Self {
Self {
image,
title,
uri,
metadata: ResultMetadata::Album,
}
}
}
#[derive(Debug, Clone, Hash)]
pub enum ResultMetadata {
Track(String),
Playlist,
Album,
Artist,
}
impl Display for ResultMetadata {
@@ -162,6 +185,7 @@
ResultMetadata::Track(v) => write!(f, "Track • {v}"),
ResultMetadata::Playlist => write!(f, "Playlist"),
ResultMetadata::Album => write!(f, "Album"),
ResultMetadata::Artist => write!(f, "Artist"),
}
}
}