Compare commits
No commits in common. "main" and "v0.2.1" have entirely different histories.
23 changed files with 1600 additions and 1507 deletions
33
.github/ISSUE_TEMPLATE/bug_report.md
vendored
33
.github/ISSUE_TEMPLATE/bug_report.md
vendored
|
@ -1,33 +0,0 @@
|
||||||
---
|
|
||||||
name: Bug report
|
|
||||||
about: Create a report to help us improve
|
|
||||||
title: ''
|
|
||||||
labels: bug
|
|
||||||
assignees: ''
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**Describe the bug**
|
|
||||||
A clear and concise description of what the bug is.
|
|
||||||
|
|
||||||
**To Reproduce**
|
|
||||||
Steps to reproduce the behavior:
|
|
||||||
1. Go to '...'
|
|
||||||
2. Click on '....'
|
|
||||||
3. Scroll down to '....'
|
|
||||||
4. See error
|
|
||||||
|
|
||||||
**Expected behavior**
|
|
||||||
A clear and concise description of what you expected to happen.
|
|
||||||
|
|
||||||
**Screenshots**
|
|
||||||
If applicable, add screenshots to help explain your problem.
|
|
||||||
|
|
||||||
**Version information (please complete the following information):**
|
|
||||||
- OS: [e.g. NixOS 23.11]
|
|
||||||
- Browser [e.g. firefox, chrome]
|
|
||||||
- Empede version [e.g. v0.2.1]
|
|
||||||
- MPD version [e.g. 0.23.15]
|
|
||||||
|
|
||||||
**Additional context**
|
|
||||||
Add any other context about the problem here.
|
|
20
.github/ISSUE_TEMPLATE/feature_request.md
vendored
20
.github/ISSUE_TEMPLATE/feature_request.md
vendored
|
@ -1,20 +0,0 @@
|
||||||
---
|
|
||||||
name: Feature request
|
|
||||||
about: Suggest an idea for this project
|
|
||||||
title: ''
|
|
||||||
labels: ''
|
|
||||||
assignees: ''
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**Is your feature request related to a problem? Please describe.**
|
|
||||||
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
|
|
||||||
|
|
||||||
**Describe the solution you'd like**
|
|
||||||
A clear and concise description of what you want to happen.
|
|
||||||
|
|
||||||
**Describe alternatives you've considered**
|
|
||||||
A clear and concise description of any alternative solutions or features you've considered.
|
|
||||||
|
|
||||||
**Additional context**
|
|
||||||
Add any other context or screenshots about the feature request here.
|
|
|
@ -1,128 +0,0 @@
|
||||||
# Contributor Covenant Code of Conduct
|
|
||||||
|
|
||||||
## Our Pledge
|
|
||||||
|
|
||||||
We as members, contributors, and leaders pledge to make participation in our
|
|
||||||
community a harassment-free experience for everyone, regardless of age, body
|
|
||||||
size, visible or invisible disability, ethnicity, sex characteristics, gender
|
|
||||||
identity and expression, level of experience, education, socio-economic status,
|
|
||||||
nationality, personal appearance, race, religion, or sexual identity
|
|
||||||
and orientation.
|
|
||||||
|
|
||||||
We pledge to act and interact in ways that contribute to an open, welcoming,
|
|
||||||
diverse, inclusive, and healthy community.
|
|
||||||
|
|
||||||
## Our Standards
|
|
||||||
|
|
||||||
Examples of behavior that contributes to a positive environment for our
|
|
||||||
community include:
|
|
||||||
|
|
||||||
* Demonstrating empathy and kindness toward other people
|
|
||||||
* Being respectful of differing opinions, viewpoints, and experiences
|
|
||||||
* Giving and gracefully accepting constructive feedback
|
|
||||||
* Accepting responsibility and apologizing to those affected by our mistakes,
|
|
||||||
and learning from the experience
|
|
||||||
* Focusing on what is best not just for us as individuals, but for the
|
|
||||||
overall community
|
|
||||||
|
|
||||||
Examples of unacceptable behavior include:
|
|
||||||
|
|
||||||
* The use of sexualized language or imagery, and sexual attention or
|
|
||||||
advances of any kind
|
|
||||||
* Trolling, insulting or derogatory comments, and personal or political attacks
|
|
||||||
* Public or private harassment
|
|
||||||
* Publishing others' private information, such as a physical or email
|
|
||||||
address, without their explicit permission
|
|
||||||
* Other conduct which could reasonably be considered inappropriate in a
|
|
||||||
professional setting
|
|
||||||
|
|
||||||
## Enforcement Responsibilities
|
|
||||||
|
|
||||||
Community leaders are responsible for clarifying and enforcing our standards of
|
|
||||||
acceptable behavior and will take appropriate and fair corrective action in
|
|
||||||
response to any behavior that they deem inappropriate, threatening, offensive,
|
|
||||||
or harmful.
|
|
||||||
|
|
||||||
Community leaders have the right and responsibility to remove, edit, or reject
|
|
||||||
comments, commits, code, wiki edits, issues, and other contributions that are
|
|
||||||
not aligned to this Code of Conduct, and will communicate reasons for moderation
|
|
||||||
decisions when appropriate.
|
|
||||||
|
|
||||||
## Scope
|
|
||||||
|
|
||||||
This Code of Conduct applies within all community spaces, and also applies when
|
|
||||||
an individual is officially representing the community in public spaces.
|
|
||||||
Examples of representing our community include using an official e-mail address,
|
|
||||||
posting via an official social media account, or acting as an appointed
|
|
||||||
representative at an online or offline event.
|
|
||||||
|
|
||||||
## Enforcement
|
|
||||||
|
|
||||||
Instances of abusive, harassing, or otherwise unacceptable behavior may be
|
|
||||||
reported to the community leaders responsible for enforcement at
|
|
||||||
coc@vijf.life.
|
|
||||||
All complaints will be reviewed and investigated promptly and fairly.
|
|
||||||
|
|
||||||
All community leaders are obligated to respect the privacy and security of the
|
|
||||||
reporter of any incident.
|
|
||||||
|
|
||||||
## Enforcement Guidelines
|
|
||||||
|
|
||||||
Community leaders will follow these Community Impact Guidelines in determining
|
|
||||||
the consequences for any action they deem in violation of this Code of Conduct:
|
|
||||||
|
|
||||||
### 1. Correction
|
|
||||||
|
|
||||||
**Community Impact**: Use of inappropriate language or other behavior deemed
|
|
||||||
unprofessional or unwelcome in the community.
|
|
||||||
|
|
||||||
**Consequence**: A private, written warning from community leaders, providing
|
|
||||||
clarity around the nature of the violation and an explanation of why the
|
|
||||||
behavior was inappropriate. A public apology may be requested.
|
|
||||||
|
|
||||||
### 2. Warning
|
|
||||||
|
|
||||||
**Community Impact**: A violation through a single incident or series
|
|
||||||
of actions.
|
|
||||||
|
|
||||||
**Consequence**: A warning with consequences for continued behavior. No
|
|
||||||
interaction with the people involved, including unsolicited interaction with
|
|
||||||
those enforcing the Code of Conduct, for a specified period of time. This
|
|
||||||
includes avoiding interactions in community spaces as well as external channels
|
|
||||||
like social media. Violating these terms may lead to a temporary or
|
|
||||||
permanent ban.
|
|
||||||
|
|
||||||
### 3. Temporary Ban
|
|
||||||
|
|
||||||
**Community Impact**: A serious violation of community standards, including
|
|
||||||
sustained inappropriate behavior.
|
|
||||||
|
|
||||||
**Consequence**: A temporary ban from any sort of interaction or public
|
|
||||||
communication with the community for a specified period of time. No public or
|
|
||||||
private interaction with the people involved, including unsolicited interaction
|
|
||||||
with those enforcing the Code of Conduct, is allowed during this period.
|
|
||||||
Violating these terms may lead to a permanent ban.
|
|
||||||
|
|
||||||
### 4. Permanent Ban
|
|
||||||
|
|
||||||
**Community Impact**: Demonstrating a pattern of violation of community
|
|
||||||
standards, including sustained inappropriate behavior, harassment of an
|
|
||||||
individual, or aggression toward or disparagement of classes of individuals.
|
|
||||||
|
|
||||||
**Consequence**: A permanent ban from any sort of public interaction within
|
|
||||||
the community.
|
|
||||||
|
|
||||||
## Attribution
|
|
||||||
|
|
||||||
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
|
|
||||||
version 2.0, available at
|
|
||||||
https://www.contributor-covenant.org/version/2/0/code_of_conduct.html.
|
|
||||||
|
|
||||||
Community Impact Guidelines were inspired by [Mozilla's code of conduct
|
|
||||||
enforcement ladder](https://github.com/mozilla/diversity).
|
|
||||||
|
|
||||||
[homepage]: https://www.contributor-covenant.org
|
|
||||||
|
|
||||||
For answers to common questions about this code of conduct, see the FAQ at
|
|
||||||
https://www.contributor-covenant.org/faq. Translations are available at
|
|
||||||
https://www.contributor-covenant.org/translations.
|
|
2211
Cargo.lock
generated
2211
Cargo.lock
generated
File diff suppressed because it is too large
Load diff
18
Cargo.toml
18
Cargo.toml
|
@ -1,7 +1,7 @@
|
||||||
[package]
|
[package]
|
||||||
name = "empede"
|
name = "empede"
|
||||||
description = "A web client for MPD"
|
description = "A web client for MPD"
|
||||||
version = "0.2.3"
|
version = "0.2.1"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
repository = "https://github.com/vijfhoek/empede"
|
repository = "https://github.com/vijfhoek/empede"
|
||||||
|
@ -9,17 +9,13 @@ repository = "https://github.com/vijfhoek/empede"
|
||||||
[dependencies]
|
[dependencies]
|
||||||
anyhow = "1.0.70"
|
anyhow = "1.0.70"
|
||||||
askama = { version = "0.12.0", default-features = false, features = ["serde-json"] }
|
askama = { version = "0.12.0", default-features = false, features = ["serde-json"] }
|
||||||
|
askama_tide = "0.15.0"
|
||||||
|
async-std = { version = "1.12.0", features = ["attributes"] }
|
||||||
infer = { version = "0.15.0", default-features = false }
|
infer = { version = "0.15.0", default-features = false }
|
||||||
percent-encoding = "2.2.0"
|
percent-encoding = "2.2.0"
|
||||||
serde = { version = "1.0.160", features = ["derive"] }
|
serde = { version = "1.0.160", features = ["derive"] }
|
||||||
serde_qs = "0.12.0"
|
serde_qs = "0.12.0"
|
||||||
askama_actix = "0.14.0"
|
tide = "0.16.0"
|
||||||
tokio = { version = "1.35.1", features = ["full"] }
|
tide-tracing = "0.0.12"
|
||||||
actix-web = "4.4.0"
|
tracing = { version = "0.1.37", default-features = false, features = ["std"] }
|
||||||
thiserror = "1.0.51"
|
tracing-subscriber = { version = "0.3.17", default-features = false, features = ["std", "fmt"] }
|
||||||
actix-files = "0.6.2"
|
|
||||||
actix-web-lab = "0.20.1"
|
|
||||||
tokio-stream = "0.1.14"
|
|
||||||
futures = "0.3.29"
|
|
||||||
async-stream = "0.3.5"
|
|
||||||
env_logger = "0.10.1"
|
|
||||||
|
|
|
@ -1,9 +1,9 @@
|
||||||
FROM rust:alpine AS builder
|
FROM rust:alpine as builder
|
||||||
WORKDIR /usr/src/empede
|
WORKDIR /usr/src/empede
|
||||||
RUN apk add --no-cache build-base
|
RUN apk add --no-cache build-base
|
||||||
COPY ./src ./src
|
COPY ./src ./src
|
||||||
COPY ./templates ./templates
|
COPY ./templates ./templates
|
||||||
COPY ./Cargo.toml ./Cargo.lock ./
|
COPY ./Cargo.* ./
|
||||||
RUN cargo install --path .
|
RUN cargo install --path .
|
||||||
|
|
||||||
FROM alpine:latest
|
FROM alpine:latest
|
||||||
|
|
Binary file not shown.
Before Width: | Height: | Size: 54 KiB After Width: | Height: | Size: 34 KiB |
84
src/main.rs
84
src/main.rs
|
@ -1,43 +1,61 @@
|
||||||
use actix_web::{middleware::Logger, web, App, HttpServer};
|
|
||||||
|
|
||||||
mod crate_version;
|
mod crate_version;
|
||||||
mod mpd;
|
mod mpd;
|
||||||
mod routes;
|
mod routes;
|
||||||
|
|
||||||
#[actix_web::main]
|
async fn sse(_req: tide::Request<()>, sender: tide::sse::Sender) -> tide::Result<()> {
|
||||||
async fn main() -> std::io::Result<()> {
|
// Update everything on connect
|
||||||
let bind = std::env::var("EMPEDE_BIND").unwrap_or("0.0.0.0:8080".into());
|
sender.send("playlist", "", None).await?;
|
||||||
let (host, port) = bind.split_once(':').unwrap();
|
sender.send("player", "", None).await?;
|
||||||
|
|
||||||
env_logger::init_from_env(env_logger::Env::new().default_filter_or("info"));
|
let mut mpd = mpd::Mpd::new();
|
||||||
|
mpd.connect().await.unwrap();
|
||||||
|
|
||||||
HttpServer::new(|| {
|
loop {
|
||||||
App::new().wrap(Logger::default()).service(
|
let systems = mpd
|
||||||
web::scope("")
|
.idle(&["playlist", "player", "database", "options"])
|
||||||
.service(routes::index::get_index)
|
|
||||||
.service(routes::player::get_player)
|
|
||||||
.service(routes::browser::get_browser)
|
|
||||||
.service(routes::art::get_art)
|
|
||||||
.service(routes::sse::idle)
|
|
||||||
.service(routes::queue::get_queue)
|
|
||||||
.service(routes::queue::post_queue)
|
|
||||||
.service(routes::queue::delete_queue)
|
|
||||||
.service(routes::queue::post_queue_move)
|
|
||||||
.service(routes::controls::post_play)
|
|
||||||
.service(routes::controls::post_pause)
|
|
||||||
.service(routes::controls::post_previous)
|
|
||||||
.service(routes::controls::post_next)
|
|
||||||
.service(routes::controls::post_consume)
|
|
||||||
.service(routes::controls::post_random)
|
|
||||||
.service(routes::controls::post_repeat)
|
|
||||||
.service(routes::controls::post_single)
|
|
||||||
.service(routes::controls::post_shuffle)
|
|
||||||
.service(actix_files::Files::new("/static", "./static")),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
.bind((host, port.parse().unwrap()))?
|
|
||||||
.run()
|
|
||||||
.await?;
|
.await?;
|
||||||
|
for system in systems {
|
||||||
|
sender.send(&system, "", None).await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_std::main]
|
||||||
|
async fn main() -> tide::Result<()> {
|
||||||
|
tracing_subscriber::fmt()
|
||||||
|
.with_max_level(tracing::Level::WARN)
|
||||||
|
.init();
|
||||||
|
|
||||||
|
let mut app = tide::new();
|
||||||
|
app.with(tide_tracing::TraceMiddleware::new());
|
||||||
|
|
||||||
|
app.at("/").get(routes::index::get_index);
|
||||||
|
app.at("/player").get(routes::player::get_player);
|
||||||
|
app.at("/browser").get(routes::browser::get_browser);
|
||||||
|
app.at("/art").get(routes::art::get_art);
|
||||||
|
|
||||||
|
app.at("/sse").get(tide::sse::endpoint(sse));
|
||||||
|
|
||||||
|
app.at("/queue").get(routes::queue::get_queue);
|
||||||
|
app.at("/queue").post(routes::queue::post_queue);
|
||||||
|
app.at("/queue").delete(routes::queue::delete_queue);
|
||||||
|
app.at("/queue/move").post(routes::queue::post_queue_move);
|
||||||
|
|
||||||
|
app.at("/play").post(routes::controls::post_play);
|
||||||
|
app.at("/pause").post(routes::controls::post_pause);
|
||||||
|
app.at("/previous").post(routes::controls::post_previous);
|
||||||
|
app.at("/next").post(routes::controls::post_next);
|
||||||
|
|
||||||
|
app.at("/consume").post(routes::controls::post_consume);
|
||||||
|
app.at("/random").post(routes::controls::post_random);
|
||||||
|
app.at("/repeat").post(routes::controls::post_repeat);
|
||||||
|
app.at("/single").post(routes::controls::post_single);
|
||||||
|
app.at("/shuffle").post(routes::controls::post_shuffle);
|
||||||
|
|
||||||
|
app.at("/static").serve_dir("static/")?;
|
||||||
|
|
||||||
|
let bind = std::env::var("EMPEDE_BIND").unwrap_or("0.0.0.0:8080".to_string());
|
||||||
|
app.listen(bind).await?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
94
src/mpd.rs
94
src/mpd.rs
|
@ -1,10 +1,11 @@
|
||||||
use std::collections::HashMap;
|
use std::{collections::HashMap, sync::OnceLock};
|
||||||
|
|
||||||
use anyhow::anyhow;
|
use anyhow::anyhow;
|
||||||
use tokio::{
|
use async_std::{
|
||||||
io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufStream},
|
io::{prelude::BufReadExt, BufReader, ReadExt, WriteExt},
|
||||||
net::TcpStream,
|
net::TcpStream,
|
||||||
sync::{Mutex, MutexGuard, OnceCell},
|
sync::{Mutex, MutexGuard},
|
||||||
|
task::block_on,
|
||||||
};
|
};
|
||||||
|
|
||||||
pub fn host() -> String {
|
pub fn host() -> String {
|
||||||
|
@ -15,7 +16,6 @@ pub fn host() -> String {
|
||||||
|
|
||||||
pub struct QueueItem {
|
pub struct QueueItem {
|
||||||
pub id: u32,
|
pub id: u32,
|
||||||
pub position: i32,
|
|
||||||
pub file: String,
|
pub file: String,
|
||||||
pub title: String,
|
pub title: String,
|
||||||
pub artist: Option<String>,
|
pub artist: Option<String>,
|
||||||
|
@ -42,21 +42,19 @@ pub enum Entry {
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub struct Mpd {
|
pub struct Mpd {
|
||||||
bufstream: Option<BufStream<TcpStream>>,
|
stream: Option<TcpStream>,
|
||||||
|
reader: Option<BufReader<TcpStream>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub static INSTANCE: OnceCell<Mutex<Mpd>> = OnceCell::const_new();
|
pub static INSTANCE: OnceLock<Mutex<Mpd>> = OnceLock::new();
|
||||||
|
|
||||||
pub async fn get_instance() -> MutexGuard<'static, Mpd> {
|
pub async fn get_instance() -> MutexGuard<'static, Mpd> {
|
||||||
INSTANCE
|
let instance = INSTANCE.get_or_init(|| {
|
||||||
.get_or_init(|| async {
|
|
||||||
let mut mpd = Mpd::new();
|
let mut mpd = Mpd::new();
|
||||||
mpd.connect().await.unwrap();
|
block_on(mpd.connect()).unwrap();
|
||||||
Mutex::from(mpd)
|
Mutex::from(mpd)
|
||||||
})
|
});
|
||||||
.await
|
instance.lock().await
|
||||||
.lock()
|
|
||||||
.await
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn command(command: &str) -> anyhow::Result<CommandResult> {
|
pub async fn command(command: &str) -> anyhow::Result<CommandResult> {
|
||||||
|
@ -118,42 +116,45 @@ impl Mpd {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
Self { bufstream: None }
|
Self {
|
||||||
|
stream: None,
|
||||||
|
reader: None,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn connect(&mut self) -> anyhow::Result<()> {
|
pub async fn connect(&mut self) -> anyhow::Result<()> {
|
||||||
let stream = TcpStream::connect(host()).await?;
|
self.stream = Some(TcpStream::connect(host()).await?);
|
||||||
let mut bufstream = BufStream::new(stream);
|
self.reader = Some(BufReader::new(self.stream.as_mut().unwrap().clone()));
|
||||||
|
|
||||||
// skip OK MPD line
|
// skip OK MPD line
|
||||||
// TODO check if it is indeed OK
|
// TODO check if it is indeed OK
|
||||||
let mut buffer = String::new();
|
let mut buffer = String::new();
|
||||||
bufstream.read_line(&mut buffer).await?;
|
self.reader.as_mut().unwrap().read_line(&mut buffer).await?;
|
||||||
|
|
||||||
let password = std::env::var("MPD_PASSWORD").unwrap_or_default();
|
let password = std::env::var("MPD_PASSWORD").unwrap_or_default();
|
||||||
if !password.is_empty() {
|
if !password.is_empty() {
|
||||||
let password = Self::escape_str(&password);
|
let password = Self::escape_str(&password);
|
||||||
bufstream
|
self.stream
|
||||||
.write_all(format!("password \"{password}\"\n").as_bytes())
|
.as_mut()
|
||||||
|
.unwrap()
|
||||||
|
.write_all(format!(r#"password "{password}"\n"#).as_bytes())
|
||||||
.await?;
|
.await?;
|
||||||
bufstream.flush().await?;
|
self.reader.as_mut().unwrap().read_line(&mut buffer).await?;
|
||||||
bufstream.read_line(&mut buffer).await?;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
bufstream
|
self.stream
|
||||||
|
.as_mut()
|
||||||
|
.unwrap()
|
||||||
.write_all("binarylimit 1048576\n".as_bytes())
|
.write_all("binarylimit 1048576\n".as_bytes())
|
||||||
.await?;
|
.await?;
|
||||||
bufstream.flush().await?;
|
self.reader.as_mut().unwrap().read_line(&mut buffer).await?;
|
||||||
bufstream.read_line(&mut buffer).await?;
|
|
||||||
|
|
||||||
self.bufstream = Some(bufstream);
|
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn read_binary_data(&mut self, size: usize) -> anyhow::Result<Vec<u8>> {
|
async fn read_binary_data(&mut self, size: usize) -> anyhow::Result<Vec<u8>> {
|
||||||
let mut binary = vec![0u8; size];
|
let mut binary = vec![0u8; size];
|
||||||
self.bufstream
|
self.reader
|
||||||
.as_mut()
|
.as_mut()
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.read_exact(&mut binary)
|
.read_exact(&mut binary)
|
||||||
|
@ -162,19 +163,11 @@ impl Mpd {
|
||||||
let mut buffer = String::new();
|
let mut buffer = String::new();
|
||||||
|
|
||||||
// Skip the newline after the binary data
|
// Skip the newline after the binary data
|
||||||
self.bufstream
|
self.reader.as_mut().unwrap().read_line(&mut buffer).await?;
|
||||||
.as_mut()
|
|
||||||
.unwrap()
|
|
||||||
.read_line(&mut buffer)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
// Skip the "OK" after the binary data
|
// Skip the "OK" after the binary data
|
||||||
// TODO Check if actually OK
|
// TODO Check if actually OK
|
||||||
self.bufstream
|
self.reader.as_mut().unwrap().read_line(&mut buffer).await?;
|
||||||
.as_mut()
|
|
||||||
.unwrap()
|
|
||||||
.read_line(&mut buffer)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
Ok(binary)
|
Ok(binary)
|
||||||
}
|
}
|
||||||
|
@ -183,21 +176,16 @@ impl Mpd {
|
||||||
let mut properties = Vec::new();
|
let mut properties = Vec::new();
|
||||||
|
|
||||||
'retry: loop {
|
'retry: loop {
|
||||||
self.bufstream
|
self.stream
|
||||||
.as_mut()
|
.as_mut()
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.write_all(format!("{command}\n").as_bytes())
|
.write_all(format!("{command}\n").as_bytes())
|
||||||
.await?;
|
.await?;
|
||||||
self.bufstream.as_mut().unwrap().flush().await?;
|
|
||||||
|
|
||||||
let mut buffer = String::new();
|
let mut buffer = String::new();
|
||||||
break 'retry (loop {
|
break 'retry (loop {
|
||||||
buffer.clear();
|
buffer.clear();
|
||||||
self.bufstream
|
self.reader.as_mut().unwrap().read_line(&mut buffer).await?;
|
||||||
.as_mut()
|
|
||||||
.unwrap()
|
|
||||||
.read_line(&mut buffer)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
if let Some((key, value)) = buffer.split_once(": ") {
|
if let Some((key, value)) = buffer.split_once(": ") {
|
||||||
let value = value.trim_end();
|
let value = value.trim_end();
|
||||||
|
@ -250,20 +238,15 @@ impl Mpd {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn add_position(&mut self, path: &str, position: &str) -> anyhow::Result<()> {
|
pub async fn add_pos(&mut self, path: &str, pos: &str) -> anyhow::Result<()> {
|
||||||
let path = Self::escape_str(path);
|
let path = Self::escape_str(path);
|
||||||
let position = Self::escape_str(position);
|
let pos = Self::escape_str(pos);
|
||||||
self.command(&format!(r#"add "{path}" "{position}""#))
|
self.command(&format!(r#"add "{path}" "{pos}""#)).await?;
|
||||||
.await?;
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn play(&mut self, position: Option<&str>) -> anyhow::Result<()> {
|
pub async fn play(&mut self) -> anyhow::Result<()> {
|
||||||
let command = match position {
|
self.command("play").await?;
|
||||||
Some(position) => format!(r#"play "{position}""#),
|
|
||||||
None => "play".into(),
|
|
||||||
};
|
|
||||||
self.command(&command).await?;
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -358,7 +341,6 @@ impl Mpd {
|
||||||
.iter()
|
.iter()
|
||||||
.map(|song| QueueItem {
|
.map(|song| QueueItem {
|
||||||
id: song["Id"].parse().unwrap(),
|
id: song["Id"].parse().unwrap(),
|
||||||
position: song["Pos"].parse().unwrap(),
|
|
||||||
file: song["file"].clone(),
|
file: song["file"].clone(),
|
||||||
title: song.get("Title").unwrap_or(&song["file"]).clone(),
|
title: song.get("Title").unwrap_or(&song["file"]).clone(),
|
||||||
artist: song.get("Artist").cloned(),
|
artist: song.get("Artist").cloned(),
|
||||||
|
|
|
@ -1,9 +1,4 @@
|
||||||
use crate::mpd;
|
use crate::mpd;
|
||||||
use actix_web::{
|
|
||||||
get,
|
|
||||||
http::header::{self, CacheDirective},
|
|
||||||
web, HttpResponse, Responder,
|
|
||||||
};
|
|
||||||
use percent_encoding::percent_decode_str;
|
use percent_encoding::percent_decode_str;
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
|
|
||||||
|
@ -13,30 +8,33 @@ struct ArtQuery {
|
||||||
path: String,
|
path: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[get("/art")]
|
pub async fn get_art(req: tide::Request<()>) -> tide::Result {
|
||||||
pub async fn get_art(query: web::Query<ArtQuery>) -> impl Responder {
|
let query: ArtQuery = req.query()?;
|
||||||
let path = percent_decode_str(&query.path).decode_utf8_lossy();
|
let path = percent_decode_str(&query.path).decode_utf8_lossy();
|
||||||
|
|
||||||
let mut mpd = mpd::get_instance().await;
|
let mut mpd = mpd::get_instance().await;
|
||||||
|
|
||||||
if let Ok(art) = mpd.albumart(&path).await {
|
let resp = if let Ok(art) = mpd.albumart(&path).await {
|
||||||
let mime = infer::get(&art)
|
let mime = infer::get(&art)
|
||||||
.map(|k| k.mime_type())
|
.map(|k| k.mime_type())
|
||||||
.unwrap_or("application/octet-stream");
|
.unwrap_or("application/octet-stream");
|
||||||
|
|
||||||
HttpResponse::Ok()
|
tide::Response::builder(tide::StatusCode::Ok)
|
||||||
.content_type(mime)
|
|
||||||
.append_header(header::CacheControl(vec![CacheDirective::MaxAge(3600)]))
|
|
||||||
.body(art)
|
.body(art)
|
||||||
|
.content_type(mime)
|
||||||
|
.header("cache-control", "max-age=3600")
|
||||||
} else if let Ok(art) = mpd.readpicture(&path).await {
|
} else if let Ok(art) = mpd.readpicture(&path).await {
|
||||||
let mime = infer::get(&art)
|
let mime = infer::get(&art)
|
||||||
.map(|k| k.mime_type())
|
.map(|k| k.mime_type())
|
||||||
.unwrap_or("application/octet-stream");
|
.unwrap_or("application/octet-stream");
|
||||||
|
|
||||||
HttpResponse::Ok()
|
tide::Response::builder(tide::StatusCode::Ok)
|
||||||
.content_type(mime)
|
|
||||||
.append_header(header::CacheControl(vec![CacheDirective::MaxAge(3600)]))
|
|
||||||
.body(art)
|
.body(art)
|
||||||
|
.content_type(mime)
|
||||||
|
.header("cache-control", "max-age=3600")
|
||||||
} else {
|
} else {
|
||||||
HttpResponse::NotFound().finish()
|
tide::Response::builder(tide::StatusCode::NotFound)
|
||||||
}
|
};
|
||||||
|
|
||||||
|
Ok(resp.into())
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,5 +1,4 @@
|
||||||
use crate::mpd;
|
use crate::mpd;
|
||||||
use actix_web::{get, web, Responder};
|
|
||||||
use askama::Template;
|
use askama::Template;
|
||||||
use percent_encoding::percent_decode_str;
|
use percent_encoding::percent_decode_str;
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
|
@ -18,17 +17,19 @@ struct BrowserQuery {
|
||||||
path: String,
|
path: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[get("/browser")]
|
pub async fn get_browser(req: tide::Request<()>) -> tide::Result {
|
||||||
pub async fn get_browser(query: web::Query<BrowserQuery>) -> impl Responder {
|
let query: BrowserQuery = req.query()?;
|
||||||
let path = percent_decode_str(&query.path).decode_utf8_lossy();
|
let path = percent_decode_str(&query.path).decode_utf8_lossy();
|
||||||
let mut mpd = mpd::get_instance().await;
|
let mut mpd = mpd::get_instance().await;
|
||||||
let entries = mpd.ls(&path).await.unwrap();
|
let entries = mpd.ls(&path).await?;
|
||||||
|
|
||||||
BrowserTemplate {
|
let template = BrowserTemplate {
|
||||||
path: Path::new(&*path)
|
path: Path::new(&*path)
|
||||||
.iter()
|
.iter()
|
||||||
.map(|s| s.to_string_lossy().to_string())
|
.map(|s| s.to_string_lossy().to_string())
|
||||||
.collect(),
|
.collect(),
|
||||||
entries,
|
entries,
|
||||||
}
|
};
|
||||||
|
|
||||||
|
Ok(template.into())
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,6 +1,3 @@
|
||||||
use actix_web::{post, web, HttpResponse, Responder};
|
|
||||||
use serde::Deserialize;
|
|
||||||
|
|
||||||
use crate::mpd;
|
use crate::mpd;
|
||||||
|
|
||||||
async fn toggle_setting(setting: &str) -> anyhow::Result<()> {
|
async fn toggle_setting(setting: &str) -> anyhow::Result<()> {
|
||||||
|
@ -14,63 +11,47 @@ async fn toggle_setting(setting: &str) -> anyhow::Result<()> {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
pub async fn post_play(_req: tide::Request<()>) -> tide::Result {
|
||||||
struct PostPlayQuery {
|
mpd::command("play").await?;
|
||||||
#[serde(default)]
|
Ok("".into())
|
||||||
position: Option<String>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[post("/play")]
|
pub async fn post_pause(_req: tide::Request<()>) -> tide::Result {
|
||||||
pub async fn post_play(query: web::Query<PostPlayQuery>) -> impl Responder {
|
mpd::command("pause 1").await?;
|
||||||
let mut mpd = mpd::get_instance().await;
|
Ok("".into())
|
||||||
mpd.play(query.position.as_deref()).await.unwrap();
|
|
||||||
HttpResponse::NoContent()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[post("/pause")]
|
pub async fn post_previous(_req: tide::Request<()>) -> tide::Result {
|
||||||
pub async fn post_pause() -> impl Responder {
|
mpd::command("previous").await?;
|
||||||
mpd::command("pause 1").await.unwrap();
|
Ok("".into())
|
||||||
HttpResponse::NoContent()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[post("/previous")]
|
pub async fn post_next(_req: tide::Request<()>) -> tide::Result {
|
||||||
pub async fn post_previous() -> impl Responder {
|
mpd::command("next").await?;
|
||||||
mpd::command("previous").await.unwrap();
|
Ok("".into())
|
||||||
HttpResponse::NoContent()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[post("/next")]
|
pub async fn post_consume(_req: tide::Request<()>) -> tide::Result {
|
||||||
pub async fn post_next() -> impl Responder {
|
toggle_setting("consume").await?;
|
||||||
mpd::command("next").await.unwrap();
|
Ok("".into())
|
||||||
HttpResponse::NoContent()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[post("/consume")]
|
pub async fn post_random(_req: tide::Request<()>) -> tide::Result {
|
||||||
pub async fn post_consume() -> impl Responder {
|
toggle_setting("random").await?;
|
||||||
toggle_setting("consume").await.unwrap();
|
Ok("".into())
|
||||||
HttpResponse::NoContent()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[post("/random")]
|
pub async fn post_repeat(_req: tide::Request<()>) -> tide::Result {
|
||||||
pub async fn post_random() -> impl Responder {
|
toggle_setting("repeat").await?;
|
||||||
toggle_setting("random").await.unwrap();
|
Ok("".into())
|
||||||
HttpResponse::NoContent()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[post("/repeat")]
|
pub async fn post_shuffle(_req: tide::Request<()>) -> tide::Result {
|
||||||
pub async fn post_repeat() -> impl Responder {
|
mpd::command("shuffle").await?;
|
||||||
toggle_setting("repeat").await.unwrap();
|
Ok("".into())
|
||||||
HttpResponse::NoContent()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[post("/shuffle")]
|
pub async fn post_single(_req: tide::Request<()>) -> tide::Result {
|
||||||
pub async fn post_shuffle() -> impl Responder {
|
toggle_setting("single").await?;
|
||||||
mpd::command("shuffle").await.unwrap();
|
Ok("".into())
|
||||||
HttpResponse::NoContent()
|
|
||||||
}
|
|
||||||
|
|
||||||
#[post("/single")]
|
|
||||||
pub async fn post_single() -> impl Responder {
|
|
||||||
toggle_setting("single").await.unwrap();
|
|
||||||
HttpResponse::NoContent()
|
|
||||||
}
|
}
|
|
@ -1,5 +1,4 @@
|
||||||
use crate::crate_version;
|
use crate::crate_version;
|
||||||
use actix_web::{get, Responder};
|
|
||||||
use askama::Template;
|
use askama::Template;
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
|
|
||||||
|
@ -13,7 +12,6 @@ struct IndexQuery {
|
||||||
path: String,
|
path: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[get("/")]
|
pub async fn get_index(_req: tide::Request<()>) -> tide::Result {
|
||||||
pub async fn get_index() -> impl Responder {
|
Ok(askama_tide::into_response(&IndexTemplate))
|
||||||
IndexTemplate
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,7 +1,6 @@
|
||||||
pub mod art;
|
pub mod art;
|
||||||
pub mod browser;
|
pub mod browser;
|
||||||
pub mod controls;
|
|
||||||
pub mod index;
|
pub mod index;
|
||||||
pub mod player;
|
pub mod player;
|
||||||
pub mod queue;
|
pub mod queue;
|
||||||
pub mod sse;
|
pub mod controls;
|
||||||
|
|
|
@ -1,14 +1,13 @@
|
||||||
use crate::mpd;
|
use crate::mpd;
|
||||||
use actix_web::{get, Responder};
|
|
||||||
use askama::Template;
|
use askama::Template;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
|
||||||
#[derive(Template)]
|
#[derive(Template)]
|
||||||
#[template(path = "player.html")]
|
#[template(path = "player.html")]
|
||||||
struct PlayerTemplate {
|
struct PlayerTemplate<'a> {
|
||||||
song: Option<HashMap<String, String>>,
|
song: Option<&'a HashMap<String, String>>,
|
||||||
name: Option<String>,
|
name: Option<String>,
|
||||||
state: String,
|
state: &'a str,
|
||||||
consume: bool,
|
consume: bool,
|
||||||
random: bool,
|
random: bool,
|
||||||
repeat: bool,
|
repeat: bool,
|
||||||
|
@ -17,11 +16,10 @@ struct PlayerTemplate {
|
||||||
duration: f32,
|
duration: f32,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[get("/player")]
|
pub async fn get_player(_req: tide::Request<()>) -> tide::Result {
|
||||||
pub async fn get_player() -> impl Responder {
|
|
||||||
let mut mpd = mpd::get_instance().await;
|
let mut mpd = mpd::get_instance().await;
|
||||||
let song = mpd.command("currentsong").await.unwrap().into_hashmap();
|
let song = mpd.command("currentsong").await?.into_hashmap();
|
||||||
let status = mpd.command("status").await.unwrap().into_hashmap();
|
let status = mpd.command("status").await?.into_hashmap();
|
||||||
|
|
||||||
let elapsed = status
|
let elapsed = status
|
||||||
.get("elapsed")
|
.get("elapsed")
|
||||||
|
@ -33,13 +31,9 @@ pub async fn get_player() -> impl Responder {
|
||||||
.unwrap_or(1.0);
|
.unwrap_or(1.0);
|
||||||
|
|
||||||
let mut template = PlayerTemplate {
|
let mut template = PlayerTemplate {
|
||||||
song: if song.is_empty() {
|
song: if song.is_empty() { None } else { Some(&song) },
|
||||||
None
|
|
||||||
} else {
|
|
||||||
Some(song.clone())
|
|
||||||
},
|
|
||||||
name: None,
|
name: None,
|
||||||
state: status["state"].clone(),
|
state: &status["state"],
|
||||||
consume: status["consume"] == "1",
|
consume: status["consume"] == "1",
|
||||||
random: status["random"] == "1",
|
random: status["random"] == "1",
|
||||||
repeat: status["repeat"] == "1",
|
repeat: status["repeat"] == "1",
|
||||||
|
@ -53,5 +47,5 @@ pub async fn get_player() -> impl Responder {
|
||||||
template.name = Some(name);
|
template.name = Some(name);
|
||||||
}
|
}
|
||||||
|
|
||||||
template
|
Ok(template.into())
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,5 +1,4 @@
|
||||||
use crate::mpd;
|
use crate::mpd;
|
||||||
use actix_web::{delete, get, post, web, HttpResponse, Responder};
|
|
||||||
use askama::Template;
|
use askama::Template;
|
||||||
use percent_encoding::percent_decode_str;
|
use percent_encoding::percent_decode_str;
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
|
@ -10,11 +9,11 @@ struct QueueTemplate {
|
||||||
queue: Vec<mpd::QueueItem>,
|
queue: Vec<mpd::QueueItem>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[get("/queue")]
|
pub async fn get_queue(_req: tide::Request<()>) -> tide::Result {
|
||||||
pub async fn get_queue() -> impl Responder {
|
|
||||||
let mut mpd = mpd::get_instance().await;
|
let mut mpd = mpd::get_instance().await;
|
||||||
let queue = mpd.playlist().await.unwrap();
|
let queue = mpd.playlist().await?;
|
||||||
QueueTemplate { queue }
|
let template = QueueTemplate { queue };
|
||||||
|
Ok(template.into())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
|
@ -28,26 +27,26 @@ struct PostQueueQuery {
|
||||||
play: bool,
|
play: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[post("/queue")]
|
pub async fn post_queue(req: tide::Request<()>) -> tide::Result {
|
||||||
pub async fn post_queue(query: web::Query<PostQueueQuery>) -> impl Responder {
|
let query: PostQueueQuery = req.query()?;
|
||||||
let path = percent_decode_str(&query.path).decode_utf8_lossy();
|
let path = percent_decode_str(&query.path).decode_utf8_lossy();
|
||||||
let mut mpd = mpd::get_instance().await;
|
let mut mpd = mpd::get_instance().await;
|
||||||
|
|
||||||
if query.replace {
|
if query.replace {
|
||||||
mpd.clear().await.unwrap();
|
mpd.clear().await?;
|
||||||
}
|
}
|
||||||
|
|
||||||
if query.next {
|
if query.next {
|
||||||
mpd.add_position(&path, "+0").await.unwrap();
|
mpd.add_pos(&path, "+0").await?;
|
||||||
} else {
|
} else {
|
||||||
mpd.add(&path).await.unwrap();
|
mpd.add(&path).await?;
|
||||||
}
|
}
|
||||||
|
|
||||||
if query.play {
|
if query.play {
|
||||||
mpd.play(None).await.unwrap();
|
mpd.play().await?;
|
||||||
}
|
}
|
||||||
|
|
||||||
HttpResponse::NoContent()
|
Ok("".into())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
|
@ -56,16 +55,17 @@ struct DeleteQueueQuery {
|
||||||
id: Option<u32>,
|
id: Option<u32>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[delete("/queue")]
|
pub async fn delete_queue(req: tide::Request<()>) -> tide::Result {
|
||||||
pub async fn delete_queue(query: web::Query<DeleteQueueQuery>) -> impl Responder {
|
let query: DeleteQueueQuery = req.query()?;
|
||||||
|
|
||||||
let mut mpd = mpd::get_instance().await;
|
let mut mpd = mpd::get_instance().await;
|
||||||
if let Some(id) = query.id {
|
if let Some(id) = query.id {
|
||||||
mpd.command(&format!("deleteid {id}")).await.unwrap();
|
mpd.command(&format!("deleteid {id}")).await?;
|
||||||
} else {
|
} else {
|
||||||
mpd.command("clear").await.unwrap();
|
mpd.command("clear").await?;
|
||||||
}
|
}
|
||||||
|
|
||||||
HttpResponse::NoContent()
|
Ok("".into())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize, Debug)]
|
#[derive(Deserialize, Debug)]
|
||||||
|
@ -74,11 +74,10 @@ struct UpdateQueueBody {
|
||||||
to: u32,
|
to: u32,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[post("/queue/move")]
|
pub async fn post_queue_move(mut req: tide::Request<()>) -> tide::Result {
|
||||||
pub async fn post_queue_move(body: web::Json<UpdateQueueBody>) -> impl Responder {
|
let body: UpdateQueueBody = req.body_json().await?;
|
||||||
let mut mpd = mpd::get_instance().await;
|
let mut mpd = mpd::get_instance().await;
|
||||||
mpd.command(&format!("move {} {}", body.from, body.to))
|
mpd.command(&format!("move {} {}", body.from, body.to))
|
||||||
.await
|
.await?;
|
||||||
.unwrap();
|
Ok("".into())
|
||||||
HttpResponse::NoContent()
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,33 +0,0 @@
|
||||||
use std::time::Duration;
|
|
||||||
|
|
||||||
use actix_web::{get, Responder};
|
|
||||||
use actix_web_lab::sse;
|
|
||||||
|
|
||||||
use crate::mpd::Mpd;
|
|
||||||
|
|
||||||
#[get("/idle")]
|
|
||||||
pub async fn idle() -> impl Responder {
|
|
||||||
let mut mpd = Mpd::new();
|
|
||||||
mpd.connect().await.unwrap();
|
|
||||||
|
|
||||||
const SYSTEMS: &[&str] = &["playlist", "player", "database", "options"];
|
|
||||||
|
|
||||||
let (tx, rx) = tokio::sync::mpsc::channel(10);
|
|
||||||
for system in SYSTEMS {
|
|
||||||
_ = tx
|
|
||||||
.send(sse::Data::new("").event(system.to_owned()).into())
|
|
||||||
.await;
|
|
||||||
}
|
|
||||||
|
|
||||||
actix_web::rt::spawn(async move {
|
|
||||||
loop {
|
|
||||||
let systems = mpd.idle(SYSTEMS).await.unwrap();
|
|
||||||
|
|
||||||
for system in systems {
|
|
||||||
_ = tx.send(sse::Data::new("").event(system).into()).await;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
sse::Sse::from_infallible_receiver(rx).with_retry_duration(Duration::from_secs(10))
|
|
||||||
}
|
|
|
@ -28,22 +28,15 @@ button {
|
||||||
background-color: transparent;
|
background-color: transparent;
|
||||||
border: none;
|
border: none;
|
||||||
color: inherit;
|
color: inherit;
|
||||||
|
padding: 0;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
display: flex;
|
display: flex;
|
||||||
line-height: 24px;
|
line-height: 24px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
padding: 0.25rem 0.5rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
button:hover {
|
|
||||||
background-color: rgba(255, 255, 255, 0.1);
|
|
||||||
border-radius: 0.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
button .material-symbols-outlined {
|
button .material-symbols-outlined {
|
||||||
margin-right: 0.25rem;
|
margin-right: 0.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.active {
|
.active {
|
||||||
color: #99f;
|
color: #99f;
|
||||||
}
|
}
|
||||||
|
@ -88,6 +81,10 @@ ul {
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.queue-header button {
|
||||||
|
margin-left: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
.queue-next {
|
.queue-next {
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
@ -96,7 +93,6 @@ ul {
|
||||||
.queue {
|
.queue {
|
||||||
margin-top: 0.5rem;
|
margin-top: 0.5rem;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
scroll-behavior: smooth;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.queue ul li {
|
.queue ul li {
|
||||||
|
@ -104,10 +100,6 @@ ul {
|
||||||
border-radius: .25rem;
|
border-radius: .25rem;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
cursor: grab;
|
|
||||||
}
|
|
||||||
.queue ul li:hover {
|
|
||||||
background-color: #223;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.queue ul li.playing {
|
.queue ul li.playing {
|
||||||
|
@ -139,33 +131,28 @@ ul {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.queue .remove button {
|
|
||||||
padding: 0.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.browser .header {
|
.browser .header {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-flow: column;
|
flex-flow: column;
|
||||||
background-color: #334;
|
background-color: #334;
|
||||||
border-radius: 0.25rem;
|
border-radius: 0.25rem;
|
||||||
padding: 0.75rem 0.5rem;
|
padding: 0.75rem 1rem;
|
||||||
margin: 16px 16px 0;
|
margin: 16px 16px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.browser .buttons {
|
.browser .buttons {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-flow: row;
|
flex-flow: row;
|
||||||
margin-top: 0.25rem;
|
margin-top: 0.75rem;
|
||||||
}
|
}
|
||||||
.browser .buttons button {
|
.browser .buttons button {
|
||||||
margin-right: 0.5rem;
|
margin-right: 1.0rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
ul.breadcrumb {
|
ul.breadcrumb {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
list-style: none;
|
list-style: none;
|
||||||
margin-left: 0.5rem;
|
|
||||||
}
|
}
|
||||||
@media (prefers-contrast: more) {
|
@media (prefers-contrast: more) {
|
||||||
ul.breadcrumb {
|
ul.breadcrumb {
|
||||||
|
@ -227,10 +214,6 @@ ul.dir li .material-symbols-outlined {
|
||||||
|
|
||||||
.albumart img {
|
.albumart img {
|
||||||
visibility: hidden;
|
visibility: hidden;
|
||||||
border-radius: 0.25rem;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
object-fit: cover;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.queue .albumart {
|
.queue .albumart {
|
||||||
|
@ -242,6 +225,13 @@ ul.dir li .material-symbols-outlined {
|
||||||
margin-right: 0.75rem;
|
margin-right: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.albumart img {
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
.track {
|
.track {
|
||||||
margin-right: 0.75rem;
|
margin-right: 0.75rem;
|
||||||
}
|
}
|
||||||
|
@ -283,11 +273,7 @@ ul.dir li .material-symbols-outlined {
|
||||||
.player .settings {
|
.player .settings {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-around;
|
justify-content: space-around;
|
||||||
padding: 0 0.5rem 0.25rem;
|
padding: 0 0.5rem 1.0rem;
|
||||||
}
|
|
||||||
|
|
||||||
.player .settings {
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.player .controls button {
|
.player .controls button {
|
||||||
|
|
4
static/vendor/Sortable.min.js
vendored
4
static/vendor/Sortable.min.js
vendored
File diff suppressed because one or more lines are too long
175
static/vendor/htmx-sse.js
vendored
175
static/vendor/htmx-sse.js
vendored
|
@ -39,19 +39,17 @@ This extension adds support for Server Sent Events to htmx. See /www/extensions
|
||||||
|
|
||||||
switch (name) {
|
switch (name) {
|
||||||
|
|
||||||
|
// Try to remove remove an EventSource when elements are removed
|
||||||
case "htmx:beforeCleanupElement":
|
case "htmx:beforeCleanupElement":
|
||||||
var internalData = api.getInternalData(evt.target)
|
var internalData = api.getInternalData(evt.target)
|
||||||
// Try to remove remove an EventSource when elements are removed
|
|
||||||
if (internalData.sseEventSource) {
|
if (internalData.sseEventSource) {
|
||||||
internalData.sseEventSource.close();
|
internalData.sseEventSource.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
return;
|
return;
|
||||||
|
|
||||||
// Try to create EventSources when elements are processed
|
// Try to create EventSources when elements are processed
|
||||||
case "htmx:afterProcessNode":
|
case "htmx:afterProcessNode":
|
||||||
ensureEventSourceOnElement(evt.target);
|
createEventSourceOnElement(evt.target);
|
||||||
registerSSE(evt.target);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
@ -92,7 +90,7 @@ This extension adds support for Server Sent Events to htmx. See /www/extensions
|
||||||
function getLegacySSESwaps(elt) {
|
function getLegacySSESwaps(elt) {
|
||||||
var legacySSEValue = api.getAttributeValue(elt, "hx-sse");
|
var legacySSEValue = api.getAttributeValue(elt, "hx-sse");
|
||||||
var returnArr = [];
|
var returnArr = [];
|
||||||
if (legacySSEValue != null) {
|
if (legacySSEValue) {
|
||||||
var values = splitOnWhitespace(legacySSEValue);
|
var values = splitOnWhitespace(legacySSEValue);
|
||||||
for (var i = 0; i < values.length; i++) {
|
for (var i = 0; i < values.length; i++) {
|
||||||
var value = values[i].split(/:(.+)/);
|
var value = values[i].split(/:(.+)/);
|
||||||
|
@ -105,23 +103,62 @@ This extension adds support for Server Sent Events to htmx. See /www/extensions
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* registerSSE looks for attributes that can contain sse events, right
|
* createEventSourceOnElement creates a new EventSource connection on the provided element.
|
||||||
* now hx-trigger and sse-swap and adds listeners based on these attributes too
|
* If a usable EventSource already exists, then it is returned. If not, then a new EventSource
|
||||||
* the closest event source
|
* is created and stored in the element's internalData.
|
||||||
*
|
|
||||||
* @param {HTMLElement} elt
|
* @param {HTMLElement} elt
|
||||||
|
* @param {number} retryCount
|
||||||
|
* @returns {EventSource | null}
|
||||||
*/
|
*/
|
||||||
function registerSSE(elt) {
|
function createEventSourceOnElement(elt, retryCount) {
|
||||||
// Find closest existing event source
|
|
||||||
var sourceElement = api.getClosestMatch(elt, hasEventSource);
|
if (elt == null) {
|
||||||
if (sourceElement == null) {
|
return null;
|
||||||
// api.triggerErrorEvent(elt, "htmx:noSSESourceError")
|
|
||||||
return null; // no eventsource in parentage, orphaned element
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set internalData and source
|
var internalData = api.getInternalData(elt);
|
||||||
var internalData = api.getInternalData(sourceElement);
|
|
||||||
var source = internalData.sseEventSource;
|
// get URL from element's attribute
|
||||||
|
var sseURL = api.getAttributeValue(elt, "sse-connect");
|
||||||
|
|
||||||
|
|
||||||
|
if (sseURL == undefined) {
|
||||||
|
var legacyURL = getLegacySSEURL(elt)
|
||||||
|
if (legacyURL) {
|
||||||
|
sseURL = legacyURL;
|
||||||
|
} else {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Connect to the EventSource
|
||||||
|
var source = htmx.createEventSource(sseURL);
|
||||||
|
internalData.sseEventSource = source;
|
||||||
|
|
||||||
|
// Create event handlers
|
||||||
|
source.onerror = function (err) {
|
||||||
|
|
||||||
|
// Log an error event
|
||||||
|
api.triggerErrorEvent(elt, "htmx:sseError", {error:err, source:source});
|
||||||
|
|
||||||
|
// If parent no longer exists in the document, then clean up this EventSource
|
||||||
|
if (maybeCloseSSESource(elt)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise, try to reconnect the EventSource
|
||||||
|
if (source.readyState === EventSource.CLOSED) {
|
||||||
|
retryCount = retryCount || 0;
|
||||||
|
var timeout = Math.random() * (2 ^ retryCount) * 500;
|
||||||
|
window.setTimeout(function() {
|
||||||
|
createEventSourceOnElement(elt, Math.min(7, retryCount+1));
|
||||||
|
}, timeout);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
source.onopen = function (evt) {
|
||||||
|
api.triggerEvent(elt, "htmx::sseOpen", {source: source});
|
||||||
|
}
|
||||||
|
|
||||||
// Add message handlers for every `sse-swap` attribute
|
// Add message handlers for every `sse-swap` attribute
|
||||||
queryAttributeOnThisOrChildren(elt, "sse-swap").forEach(function(child) {
|
queryAttributeOnThisOrChildren(elt, "sse-swap").forEach(function(child) {
|
||||||
|
@ -137,14 +174,10 @@ This extension adds support for Server Sent Events to htmx. See /www/extensions
|
||||||
var sseEventName = sseEventNames[i].trim();
|
var sseEventName = sseEventNames[i].trim();
|
||||||
var listener = function(event) {
|
var listener = function(event) {
|
||||||
|
|
||||||
// If the source is missing then close SSE
|
// If the parent is missing then close SSE and remove listener
|
||||||
if (maybeCloseSSESource(sourceElement)) {
|
if (maybeCloseSSESource(elt)) {
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// If the body no longer contains the element, remove the listener
|
|
||||||
if (!api.bodyContains(child)) {
|
|
||||||
source.removeEventListener(sseEventName, listener);
|
source.removeEventListener(sseEventName, listener);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// swap the response into the DOM and trigger a notification
|
// swap the response into the DOM and trigger a notification
|
||||||
|
@ -153,7 +186,7 @@ This extension adds support for Server Sent Events to htmx. See /www/extensions
|
||||||
};
|
};
|
||||||
|
|
||||||
// Register the new listener
|
// Register the new listener
|
||||||
api.getInternalData(child).sseEventListener = listener;
|
api.getInternalData(elt).sseEventListener = listener;
|
||||||
source.addEventListener(sseEventName, listener);
|
source.addEventListener(sseEventName, listener);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
@ -171,85 +204,23 @@ This extension adds support for Server Sent Events to htmx. See /www/extensions
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// remove the sse: prefix from here on out
|
var listener = function(event) {
|
||||||
sseEventName = sseEventName.substr(4);
|
|
||||||
|
|
||||||
var listener = function() {
|
// If parent is missing, then close SSE and remove listener
|
||||||
if (maybeCloseSSESource(sourceElement)) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!api.bodyContains(child)) {
|
|
||||||
source.removeEventListener(sseEventName, listener);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* ensureEventSourceOnElement creates a new EventSource connection on the provided element.
|
|
||||||
* If a usable EventSource already exists, then it is returned. If not, then a new EventSource
|
|
||||||
* is created and stored in the element's internalData.
|
|
||||||
* @param {HTMLElement} elt
|
|
||||||
* @param {number} retryCount
|
|
||||||
* @returns {EventSource | null}
|
|
||||||
*/
|
|
||||||
function ensureEventSourceOnElement(elt, retryCount) {
|
|
||||||
|
|
||||||
if (elt == null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// handle extension source creation attribute
|
|
||||||
queryAttributeOnThisOrChildren(elt, "sse-connect").forEach(function(child) {
|
|
||||||
var sseURL = api.getAttributeValue(child, "sse-connect");
|
|
||||||
if (sseURL == null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
ensureEventSource(child, sseURL, retryCount);
|
|
||||||
});
|
|
||||||
|
|
||||||
// handle legacy sse, remove for HTMX2
|
|
||||||
queryAttributeOnThisOrChildren(elt, "hx-sse").forEach(function(child) {
|
|
||||||
var sseURL = getLegacySSEURL(child);
|
|
||||||
if (sseURL == null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
ensureEventSource(child, sseURL, retryCount);
|
|
||||||
});
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
function ensureEventSource(elt, url, retryCount) {
|
|
||||||
var source = htmx.createEventSource(url);
|
|
||||||
|
|
||||||
source.onerror = function(err) {
|
|
||||||
|
|
||||||
// Log an error event
|
|
||||||
api.triggerErrorEvent(elt, "htmx:sseError", { error: err, source: source });
|
|
||||||
|
|
||||||
// If parent no longer exists in the document, then clean up this EventSource
|
|
||||||
if (maybeCloseSSESource(elt)) {
|
if (maybeCloseSSESource(elt)) {
|
||||||
|
source.removeEventListener(sseEventName, listener);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Otherwise, try to reconnect the EventSource
|
// Trigger events to be handled by the rest of htmx
|
||||||
if (source.readyState === EventSource.CLOSED) {
|
htmx.trigger(child, sseEventName, event);
|
||||||
retryCount = retryCount || 0;
|
htmx.trigger(child, "htmx:sseMessage", event);
|
||||||
var timeout = Math.random() * (2 ^ retryCount) * 500;
|
|
||||||
window.setTimeout(function() {
|
|
||||||
ensureEventSourceOnElement(elt, Math.min(7, retryCount + 1));
|
|
||||||
}, timeout);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
source.onopen = function(evt) {
|
|
||||||
api.triggerEvent(elt, "htmx:sseOpen", { source: source });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
api.getInternalData(elt).sseEventSource = source;
|
// Register the new listener
|
||||||
|
api.getInternalData(elt).sseEventListener = listener;
|
||||||
|
source.addEventListener(sseEventName.slice(4), listener);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -282,12 +253,12 @@ This extension adds support for Server Sent Events to htmx. See /www/extensions
|
||||||
var result = [];
|
var result = [];
|
||||||
|
|
||||||
// If the parent element also contains the requested attribute, then add it to the results too.
|
// If the parent element also contains the requested attribute, then add it to the results too.
|
||||||
if (api.hasAttribute(elt, attributeName)) {
|
if (api.hasAttribute(elt, attributeName) || api.hasAttribute(elt, "hx-sse")) {
|
||||||
result.push(elt);
|
result.push(elt);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Search all child nodes that match the requested attribute
|
// Search all child nodes that match the requested attribute
|
||||||
elt.querySelectorAll("[" + attributeName + "], [data-" + attributeName + "]").forEach(function(node) {
|
elt.querySelectorAll("[" + attributeName + "], [data-" + attributeName + "], [hx-sse], [data-hx-sse]").forEach(function(node) {
|
||||||
result.push(node);
|
result.push(node);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -348,8 +319,4 @@ This extension adds support for Server Sent Events to htmx. See /www/extensions
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function hasEventSource(node) {
|
|
||||||
return api.getInternalData(node).sseEventSource != null;
|
|
||||||
}
|
|
||||||
|
|
||||||
})();
|
})();
|
2
static/vendor/htmx.min.js
vendored
2
static/vendor/htmx.min.js
vendored
File diff suppressed because one or more lines are too long
|
@ -24,7 +24,7 @@
|
||||||
</script>
|
</script>
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body hx-ext="sse" sse-connect="/idle">
|
<body hx-ext="sse" sse-connect="/sse">
|
||||||
<div
|
<div
|
||||||
class="browser"
|
class="browser"
|
||||||
hx-trigger="load,sse:database"
|
hx-trigger="load,sse:database"
|
||||||
|
|
|
@ -3,12 +3,7 @@
|
||||||
|
|
||||||
<ul>
|
<ul>
|
||||||
{% for item in queue %}
|
{% for item in queue %}
|
||||||
<li
|
<li {% if item.playing %}class="playing"{% endif %}>
|
||||||
{% if item.playing %}class="playing"{% endif %}
|
|
||||||
hx-post="/play?position={{ item.position|urlencode }}"
|
|
||||||
hx-trigger="click,keyup[key='Enter']"
|
|
||||||
hx-swap="none"
|
|
||||||
>
|
|
||||||
<div class="albumart">
|
<div class="albumart">
|
||||||
<img
|
<img
|
||||||
src="/art?path={{ item.file|urlencode }}"
|
src="/art?path={{ item.file|urlencode }}"
|
||||||
|
@ -31,14 +26,6 @@
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
htmx.onLoad(() => {
|
htmx.onLoad(() => {
|
||||||
const scrollCurrentSongIntoView = () => {
|
|
||||||
const hoveredSong = document.querySelector(".queue li:hover");
|
|
||||||
if (hoveredSong === null) {
|
|
||||||
const currentSong = document.querySelector(".queue li.playing");
|
|
||||||
currentSong?.scrollIntoView({ block: "nearest" });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const isReduced = window
|
const isReduced = window
|
||||||
.matchMedia("(prefers-reduced-motion: reduce)")
|
.matchMedia("(prefers-reduced-motion: reduce)")
|
||||||
.matches;
|
.matches;
|
||||||
|
@ -51,7 +38,5 @@ htmx.onLoad(() => {
|
||||||
body: JSON.stringify({from: event.oldIndex, to: event.newIndex}),
|
body: JSON.stringify({from: event.oldIndex, to: event.newIndex}),
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
scrollCurrentSongIntoView();
|
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
Loading…
Reference in a new issue