Several changes:

- Add MPD_HOST and MPD_PORT environment variables
- Improve styling
- Move browser to separate template
- Reload directory on database update
This commit is contained in:
Sijmen 2023-04-27 03:40:19 +02:00
parent 2f0c35d6fe
commit b2ff3b60c8
Signed by: vijfhoek
GPG Key ID: DAF7821E067D9C48
6 changed files with 143 additions and 73 deletions

View File

@ -29,7 +29,7 @@ struct IndexQuery {
path: String, path: String,
} }
async fn index(req: tide::Request<()>) -> tide::Result { async fn get_index(req: tide::Request<()>) -> tide::Result {
let query: IndexQuery = req.query()?; let query: IndexQuery = req.query()?;
let entries = mpd::ls(&query.path)?; let entries = mpd::ls(&query.path)?;
let queue = mpd::playlist()?; let queue = mpd::playlist()?;
@ -94,6 +94,28 @@ async fn get_player(_req: tide::Request<()>) -> tide::Result {
Ok(template.into()) Ok(template.into())
} }
#[derive(Template)]
#[template(path = "browser.html")]
struct BrowserTemplate {
path: Vec<String>,
entries: Vec<mpd::Entry>,
}
async fn get_browser(req: tide::Request<()>) -> tide::Result {
let query: IndexQuery = req.query()?;
let entries = mpd::ls(&query.path)?;
let template = BrowserTemplate {
path: Path::new(&query.path)
.iter()
.map(|s| s.to_string_lossy().to_string())
.collect(),
entries,
};
Ok(template.into())
}
#[derive(Deserialize)] #[derive(Deserialize)]
struct PostQueueQuery { struct PostQueueQuery {
path: String, path: String,
@ -139,7 +161,7 @@ async fn get_art(req: tide::Request<()>) -> tide::Result {
async fn sse(_req: tide::Request<()>, sender: tide::sse::Sender) -> tide::Result<()> { async fn sse(_req: tide::Request<()>, sender: tide::sse::Sender) -> tide::Result<()> {
// Needs to be async and all async mpd libraries suck // Needs to be async and all async mpd libraries suck
let mut stream = TcpStream::connect(mpd::HOST).await?; let mut stream = TcpStream::connect(mpd::host()).await?;
let mut reader = BufReader::new(stream.clone()); let mut reader = BufReader::new(stream.clone());
// skip OK MPD line // skip OK MPD line
@ -152,7 +174,7 @@ async fn sse(_req: tide::Request<()>, sender: tide::sse::Sender) -> tide::Result
sender.send("player", "", None).await?; sender.send("player", "", None).await?;
loop { loop {
stream.write_all(b"idle playlist player\n").await?; stream.write_all(b"idle playlist player database\n").await?;
loop { loop {
buffer.clear(); buffer.clear();
@ -179,10 +201,11 @@ async fn main() -> tide::Result<()> {
let mut app = tide::new(); let mut app = tide::new();
app.with(tide_tracing::TraceMiddleware::new()); app.with(tide_tracing::TraceMiddleware::new());
app.at("/").get(index); app.at("/").get(get_index);
app.at("/queue").get(get_queue); app.at("/queue").get(get_queue);
app.at("/player").get(get_player); app.at("/player").get(get_player);
app.at("/art").get(get_art); app.at("/art").get(get_art);
app.at("/browser").get(get_browser);
app.at("/sse").get(tide::sse::endpoint(sse)); app.at("/sse").get(tide::sse::endpoint(sse));

View File

@ -2,10 +2,14 @@ use std::borrow::Cow;
use mpdrs::lsinfo::LsInfoResponse; use mpdrs::lsinfo::LsInfoResponse;
pub(crate) const HOST: &str = "192.168.1.203:6600"; pub(crate) fn host() -> String {
let host = std::env::var("MPD_HOST").unwrap_or("localhost".to_string());
let port = std::env::var("MPD_PORT").unwrap_or("6600".to_string());
format!("{host}:{port}")
}
pub(crate) fn connect() -> Result<mpdrs::Client, mpdrs::error::Error> { pub(crate) fn connect() -> Result<mpdrs::Client, mpdrs::error::Error> {
mpdrs::Client::connect(HOST) mpdrs::Client::connect(host())
} }
pub(crate) fn ls(path: &str) -> anyhow::Result<Vec<Entry>> { pub(crate) fn ls(path: &str) -> anyhow::Result<Vec<Entry>> {
@ -41,7 +45,9 @@ pub(crate) fn ls(path: &str) -> anyhow::Result<Vec<Entry>> {
} }
pub(crate) struct QueueItem { pub(crate) struct QueueItem {
pub(crate) file: String,
pub(crate) title: String, pub(crate) title: String,
pub(crate) artist: Option<String>,
pub(crate) playing: bool, pub(crate) playing: bool,
} }
@ -54,7 +60,9 @@ pub(crate) fn playlist() -> anyhow::Result<Vec<QueueItem>> {
.queue()? .queue()?
.into_iter() .into_iter()
.map(|song| QueueItem { .map(|song| QueueItem {
file: song.file.clone(),
title: song.title.as_ref().unwrap_or(&song.file).clone(), title: song.title.as_ref().unwrap_or(&song.file).clone(),
artist: song.artist.clone(),
playing: current == song.place, playing: current == song.place,
}) })
.collect(); .collect();

43
templates/browser.html Normal file
View File

@ -0,0 +1,43 @@
{# #}
<ul class="breadcrumb">
<li><a href="?path=">Root</a></li>
{% for (i, component) in path.iter().enumerate() %}
<li>
{% if i == path.len() - 1 %}
{{ component }}
{% else %}
<a href="?path={{ path[..i + 1].join("/") }}">
{{ component }}
</a>
{% endif %}
</li>
{% endfor %}
</ul>
<ul class="dir" hx-boost="true">
{% for entry in entries %}
{% match entry %}
{% when mpd::Entry::Song with { name, path, artist } %}
<li hx-post="/queue?path={{path}}" hx-swap="none" role="button">
<span class="material-symbols-outlined">music_note</span>
<!-- img src="/art?path={{path}}" -->
<div class="song">
<div class="song__name">{{ name }}</div>
<div class="song__artist">{{ artist }}</div>
</a>
</li>
{% when mpd::Entry::Directory with { name, path }%}
<li onclick="window.location = '?path={{path}}'">
<span class="material-symbols-outlined">folder</span>
<a href="?path={{path}}">{{ name }}</a>
</li>
{% when mpd::Entry::Playlist with { name, path } %}
<li hx-post="/queue?path={{path}}" hx-swap="none" role="button" >
<span class="material-symbols-outlined">playlist_play</span>
<div class="song">
<div class="song__name">{{ name }}</div>
</a>
</li>
{% endmatch %}
{% endfor %}
</ul>

View File

@ -10,12 +10,17 @@
<link href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADAAAAAwCAYAAABXAvmHAAABqklEQVRoQ+2XOy8FURSF720oJRK3U3lfohYahULjF6CU3Erjf0hoJH6BUqeWeIRGId5RoVMpaH1bLpkIkz17ztw5wz7JSiaZvfesddY+j6nXKj7qFedfcwFlO+gO/AcHbhE5pBR6R9ywMvYjrBMt5ALSHHEHFP3qLVR2C90kdpZTnt9SCD3wblnh6ldIp9fACF+Wlgo2XIBiKpOL2B34PmHeQt5Cihko+xzwRewO5GxTbyFvob/cQnKSL4J50ATjoAucgE2wA64S12n5YZcf92Ajz1WiBxa7YDaFzTXvGqC3HRONgG4Iyc/JZMapjEbANsRXMpKX8Ciu0xMQOTeQl5QoHNiAyKpRQBQOHEN+qsoCLiAvW6ZlDJJ0b0n8LceyjR5QbMZIQrbeF2Puj2kWAVtUahlIPJLTb8hLTbEIkFN3z0BknZw1Q15wAVLwEExnICNtMwCeM+SoQi0OSOE+sA/GFF95JWYOyO4VfFgFCBHNXeiJuAVwFpx5u2AeAZ+clniQdSE30VEgpC/BEZAbqThQ2AghoDBymsIuQDNLRca4A0XOrqZ25R14B64XVDFuhNlbAAAAAElFTkSuQmCC" rel="icon" type="image/png"> <link href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADAAAAAwCAYAAABXAvmHAAABqklEQVRoQ+2XOy8FURSF720oJRK3U3lfohYahULjF6CU3Erjf0hoJH6BUqeWeIRGId5RoVMpaH1bLpkIkz17ztw5wz7JSiaZvfesddY+j6nXKj7qFedfcwFlO+gO/AcHbhE5pBR6R9ywMvYjrBMt5ALSHHEHFP3qLVR2C90kdpZTnt9SCD3wblnh6ldIp9fACF+Wlgo2XIBiKpOL2B34PmHeQt5Cihko+xzwRewO5GxTbyFvob/cQnKSL4J50ATjoAucgE2wA64S12n5YZcf92Ajz1WiBxa7YDaFzTXvGqC3HRONgG4Iyc/JZMapjEbANsRXMpKX8Ciu0xMQOTeQl5QoHNiAyKpRQBQOHEN+qsoCLiAvW6ZlDJJ0b0n8LceyjR5QbMZIQrbeF2Puj2kWAVtUahlIPJLTb8hLTbEIkFN3z0BknZw1Q15wAVLwEExnICNtMwCeM+SoQi0OSOE+sA/GFF95JWYOyO4VfFgFCBHNXeiJuAVwFpx5u2AeAZ+clniQdSE30VEgpC/BEZAbqThQ2AghoDBymsIuQDNLRca4A0XOrqZ25R14B64XVDFuhNlbAAAAAElFTkSuQmCC" rel="icon" type="image/png">
<style> <style>
html {
height: 100%;
}
body { body {
font-family: sans; font-family: sans;
background-color: #112; background-color: #112;
color: #fff; color: #fff;
display: flex; display: flex;
margin: 0; margin: 0;
height: 100%;
} }
body > div { body > div {
@ -24,6 +29,9 @@
.browser { .browser {
flex: 1; flex: 1;
display: flex;
flex-flow: column;
height: 100%;
} }
a { a {
@ -43,13 +51,14 @@
} }
ul.queue li { ul.queue li {
padding: 1.0rem 0.75rem; padding: 0 0.5rem;
border-radius: .25rem; border-radius: .25rem;
display: flex;
align-items: center;
} }
ul.queue li.playing { ul.queue li.playing {
background-color: #334; background-color: #334;
font-weight: bold;
} }
ul.breadcrumb { ul.breadcrumb {
@ -70,6 +79,11 @@
content: "/"; content: "/";
} }
ul.dir {
overflow: auto;
flex: 1;
}
ul.dir li { ul.dir li {
cursor: pointer; cursor: pointer;
padding: .75rem .75rem; padding: .75rem .75rem;
@ -82,7 +96,7 @@
ul.dir li img { ul.dir li img {
width: 48px; width: 48px;
height: 48px; height: 48px;
object-fit: contain; object-fit: cover;
} }
ul.dir li:hover { ul.dir li:hover {
@ -98,6 +112,24 @@
font-weight: bold; font-weight: bold;
} }
.albumart {
border-radius: 0.25rem;
}
.queue .albumart {
margin: 0.75rem;
margin-left: 0;
width: 48px;
height: 48px;
}
.albumart img {
border-radius: 0.25rem;
width: 100%;
height: 100%;
object-fit: cover;
}
.player { .player {
width: 25rem; width: 25rem;
} }
@ -129,78 +161,36 @@
flex: 1; flex: 1;
} }
.player .albumart { .nowplaying .albumart {
margin-right: 1.0rem;
background-color: #445;
width: 80px; width: 80px;
height: 80px; height: 80px;
object-fit: contain;
display: block;
margin-right: 1.0rem;
border-radius: 0.25rem;
} }
.player .metadata { .nowplaying .metadata {
flex: 1; flex: 1;
} }
.player .idle { .nowplaying .idle {
font-weight: bold; font-weight: bold;
text-align: center; text-align: center;
} }
</style> </style>
</head> </head>
<body> <body hx-ext="sse" sse-connect="/sse">
<div class="browser"> <div class="browser" hx-trigger="sse:database" hx-get="/browser">
<ul class="breadcrumb"> {% include "browser.html" %}
<li><a href="?path=">Root</a></li>
{% for (i, component) in path.iter().enumerate() %}
<li>
{% if i == path.len() - 1 %}
{{ component }}
{% else %}
<a href="?path={{ path[..i + 1].join("/") }}">
{{ component }}
</a>
{% endif %}
</li>
{% endfor %}
</ul>
<ul class="dir">
{% for entry in entries %}
{% match entry %}
{% when mpd::Entry::Song with { name, path, artist } %}
<li
onclick="(new Image()).src = '/art?path={{path}}'"
hx-post="/queue?path={{path}}" hx-swap="none" role="button"
>
<span class="material-symbols-outlined">music_note</span>
<!-- img src="/art?path={{path}}" -->
<div class="song">
<div class="song__name">{{ name }}</div>
<div class="song__artist">{{ artist }}</div>
</a>
</li>
{% when mpd::Entry::Directory with { name, path }%}
<li onclick="window.location = '?path={{path}}'">
<span class="material-symbols-outlined">folder</span>
<a href="?path={{path}}">{{ name }}</a>
</li>
{% when mpd::Entry::Playlist with { name, path } %}
<li hx-post="/queue?path={{path}}" hx-swap="none" role="button" >
<span class="material-symbols-outlined">playlist_play</span>
<div class="song">
<div class="song__name">{{ name }}</div>
</a>
</li>
{% endmatch %}
{% endfor %}
</ul>
</div> </div>
<div class="player" hx-ext="sse" sse-connect="/sse"> <div class="player">
<div hx-trigger="sse:player" hx-get="/player"></div> <div hx-trigger="sse:player" hx-get="/player">
<div hx-trigger="sse:playlist,sse:player" hx-get="/queue"></div> {% include "player.html" %}
</div>
<div hx-trigger="sse:playlist,sse:player" hx-get="/queue">
{% include "queue.html" %}
</div>
</div> </div>
</body> </body>
</html> </html>

View File

@ -3,11 +3,9 @@
<div class="nowplaying"> <div class="nowplaying">
<div class="current"> <div class="current">
{% if let Some(song) = song %} {% if let Some(song) = song %}
<img <div class="albumart">
class="albumart" <img src="/art?path={{ song.file }}" onerror="this.style.opacity = 0">
src="/art?path={{ song.file }}" </div>
onerror="this.style.opacity = 0"
>
<div class="metadata"> <div class="metadata">
{% if let Some(name) = name %} {% if let Some(name) = name %}

View File

@ -3,7 +3,15 @@
<ul class="queue"> <ul class="queue">
{% for item in queue %} {% for item in queue %}
<li {% if item.playing %}class="playing"{% endif %}> <li {% if item.playing %}class="playing"{% endif %}>
<input type="checkbox" /> {{ item.title }} <div class="albumart">
<img src="/art?path={{ item.file }}">
</div>
<div class="metadata">
<div class="song__name">{{ item.title }}</div>
{% if let Some(artist) = item.artist %}
<div class="song__artist">{{ artist }}</div>
{% endif %}
</div>
</li> </li>
{% endfor %} {% endfor %}
</ul> </ul>