Compare commits
119 commits
Author | SHA1 | Date | |
---|---|---|---|
d1fd1da54b | |||
31a2719ca2 | |||
2768cbe2af | |||
7b59d7db50 | |||
f4b66a2c5f | |||
20bc6352b6 | |||
69a1ca0a6c | |||
25889df62e | |||
29103c1c8a | |||
466f10e07a | |||
88acc76463 | |||
546d7b4703 | |||
51ffc1f671 | |||
992f286c0a | |||
8a01102302 | |||
59bc10a329 | |||
34e093b63b | |||
fbca4466d0 | |||
16e4052890 | |||
31549b5b49 | |||
9c7871ed87 | |||
d566d8792f | |||
b9ac839964 | |||
4eb74be839 | |||
b872019cb0 | |||
f05e17ee3a | |||
02f4025de3 | |||
f338ab751a | |||
4119956810 | |||
ef43275561 | |||
|
b3525c786f | ||
b8416b29dd | |||
3900a5c5c0 | |||
c2fbf6c916 | |||
c6c61570e9 | |||
43139b2b4b | |||
dae598e15c | |||
9dfa38225f | |||
62651ea80b | |||
1cf1429753 | |||
05dddfde6d | |||
26a3381a25 | |||
138446e040 | |||
8e7a087e41 | |||
41a06eb9d1 | |||
039509a08d | |||
751d19f4f3 | |||
5e1faf887e | |||
3b018da07e | |||
cac047d121 | |||
1fb778da54 | |||
12baf4095b | |||
e2ed3dc55b | |||
307bbd804b | |||
95b722c606 | |||
657b7245f4 | |||
e713a65f06 | |||
63a365d11d | |||
68a00bfe80 | |||
f610171d7d | |||
4eab50d8cc | |||
f7a63f7120 | |||
9ccacd92cb | |||
d0ec23dc5a | |||
c4936dc4ff | |||
7a1adb37b2 | |||
be5cab22fd | |||
68b05c85e7 | |||
fcf325da52 | |||
88b9378678 | |||
7ecde917f9 | |||
ef46017ed8 | |||
4455b9013b | |||
66d263bc5f | |||
ee6f27f692 | |||
0a1fb9a457 | |||
6c3719222c | |||
da8793f3f5 | |||
5c500e0f30 | |||
f095160817 | |||
b14f034a50 | |||
c3b9e3fb72 | |||
6088dde2da | |||
f3e57a6f4c | |||
7b9684e4fb | |||
3ec9af38e0 | |||
b81227265c | |||
e03e48d08f | |||
1ffc0dbdd3 | |||
fe6241f109 | |||
860c66943c | |||
96135d5b34 | |||
2298922333 | |||
04aee1a0d8 | |||
24cecab594 | |||
2521de291d | |||
38a47a4a10 | |||
700a5ad1ac | |||
092c82b6cd | |||
18e0acefe5 | |||
fa4551b7b0 | |||
2c248dcaa0 | |||
1f0b5b4ded | |||
6aa01df7d7 | |||
76fb64e2ee | |||
e4b591d7a9 | |||
74798b2f74 | |||
db9dff04eb | |||
15ebb9fe78 | |||
6dfbc433a1 | |||
50bd04fe7c | |||
f7eb7f5837 | |||
de31724682 | |||
6fe5107836 | |||
25790a784d | |||
743283a9ec | |||
158a45c9ae | |||
0b50c53b22 | |||
feb8f131a1 |
39 changed files with 2898 additions and 1910 deletions
1
.dockerignore
Normal file
1
.dockerignore
Normal file
|
@ -0,0 +1 @@
|
||||||
|
target/
|
129
.drone.yml
Normal file
129
.drone.yml
Normal file
|
@ -0,0 +1,129 @@
|
||||||
|
kind: pipeline
|
||||||
|
type: docker
|
||||||
|
name: default
|
||||||
|
|
||||||
|
platform:
|
||||||
|
os: linux
|
||||||
|
arch: amd64
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: restore-cache-with-filesystem
|
||||||
|
image: meltwater/drone-cache
|
||||||
|
pull: true
|
||||||
|
settings:
|
||||||
|
backend: "filesystem"
|
||||||
|
restore: true
|
||||||
|
cache_key: "volume"
|
||||||
|
mount:
|
||||||
|
- target
|
||||||
|
- /usr/local/cargo/env
|
||||||
|
volumes:
|
||||||
|
- name: cache
|
||||||
|
path: /tmp/cache
|
||||||
|
|
||||||
|
- name: lint
|
||||||
|
image: rust:latest
|
||||||
|
commands:
|
||||||
|
- rustup component add rustfmt clippy
|
||||||
|
- cargo fmt --check
|
||||||
|
- cargo clippy
|
||||||
|
depends_on:
|
||||||
|
- restore-cache-with-filesystem
|
||||||
|
|
||||||
|
- name: build-x86_64-unknown-linux-gnu
|
||||||
|
image: rust:latest
|
||||||
|
commands:
|
||||||
|
- cargo build --release
|
||||||
|
- strip target/release/empede
|
||||||
|
depends_on:
|
||||||
|
- restore-cache-with-filesystem
|
||||||
|
|
||||||
|
- name: build-aarch64-unknown-linux-gnu
|
||||||
|
image: rust:latest
|
||||||
|
commands:
|
||||||
|
- apt-get update
|
||||||
|
- apt-get install -y gcc-aarch64-linux-gnu binutils-aarch64-linux-gnu
|
||||||
|
- rustup target add aarch64-unknown-linux-gnu
|
||||||
|
- cargo build --target=aarch64-unknown-linux-gnu --release --config target.aarch64-unknown-linux-gnu.linker=\"aarch64-linux-gnu-gcc\"
|
||||||
|
- aarch64-linux-gnu-strip target/aarch64-unknown-linux-gnu/release/empede
|
||||||
|
when:
|
||||||
|
event: tag
|
||||||
|
depends_on:
|
||||||
|
- restore-cache-with-filesystem
|
||||||
|
|
||||||
|
- name: build-x86_64-pc-windows-gnu
|
||||||
|
image: rust:latest
|
||||||
|
commands:
|
||||||
|
- apt-get update && apt-get install -y mingw-w64
|
||||||
|
- rustup target add x86_64-pc-windows-gnu
|
||||||
|
- cargo build --target=x86_64-pc-windows-gnu --release --config target.x86_64-pc-windows-gnu.linker=\"x86_64-w64-mingw32-gcc\"
|
||||||
|
- x86_64-w64-mingw32-strip target/x86_64-pc-windows-gnu/release/empede.exe
|
||||||
|
when:
|
||||||
|
event: tag
|
||||||
|
depends_on:
|
||||||
|
- restore-cache-with-filesystem
|
||||||
|
|
||||||
|
- name: package
|
||||||
|
image: alpine
|
||||||
|
commands:
|
||||||
|
- apk add --no-cache tar gzip zip
|
||||||
|
|
||||||
|
# x86_64-unknown-linux-gnu
|
||||||
|
- mkdir empede-x86_64-unknown-linux-gnu-${DRONE_TAG}
|
||||||
|
- cp -r target/release/empede static/ README.md empede-x86_64-unknown-linux-gnu-${DRONE_TAG}/
|
||||||
|
- tar czf empede-x86_64-unknown-linux-gnu-${DRONE_TAG}.tar.gz empede-x86_64-unknown-linux-gnu-${DRONE_TAG}/
|
||||||
|
|
||||||
|
# aarch64-unknown-linux-gnu
|
||||||
|
- mkdir empede-aarch64-unknown-linux-gnu-${DRONE_TAG}
|
||||||
|
- cp -r target/aarch64-unknown-linux-gnu/release/empede static/ README.md empede-aarch64-unknown-linux-gnu-${DRONE_TAG}/
|
||||||
|
- tar czf empede-aarch64-unknown-linux-gnu-${DRONE_TAG}.tar.gz empede-aarch64-unknown-linux-gnu-${DRONE_TAG}/
|
||||||
|
|
||||||
|
# x86_64-pc-windows-gnu
|
||||||
|
- mkdir empede-x86_64-pc-windows-gnu-${DRONE_TAG}
|
||||||
|
- cp -r target/x86_64-pc-windows-gnu/release/empede.exe static/ README.md empede-x86_64-pc-windows-gnu-${DRONE_TAG}/
|
||||||
|
- zip -r empede-x86_64-pc-windows-gnu-${DRONE_TAG}.zip empede-x86_64-pc-windows-gnu-${DRONE_TAG}/
|
||||||
|
depends_on:
|
||||||
|
- build-aarch64-unknown-linux-gnu
|
||||||
|
- build-x86_64-unknown-linux-gnu
|
||||||
|
- build-x86_64-pc-windows-gnu
|
||||||
|
when:
|
||||||
|
event: tag
|
||||||
|
|
||||||
|
- name: gitea_release
|
||||||
|
image: plugins/gitea-release
|
||||||
|
settings:
|
||||||
|
api_key:
|
||||||
|
from_secret: GITEA_TOKEN
|
||||||
|
base_url: https://git.sijman.nl
|
||||||
|
files:
|
||||||
|
- empede-aarch64-unknown-linux-gnu-${DRONE_TAG}.tar.gz
|
||||||
|
- empede-x86_64-unknown-linux-gnu-${DRONE_TAG}.tar.gz
|
||||||
|
- empede-x86_64-pc-windows-gnu-${DRONE_TAG}.zip
|
||||||
|
depends_on:
|
||||||
|
- package
|
||||||
|
when:
|
||||||
|
event: tag
|
||||||
|
|
||||||
|
- name: rebuild-cache-with-filesystem
|
||||||
|
image: meltwater/drone-cache
|
||||||
|
pull: true
|
||||||
|
settings:
|
||||||
|
backend: "filesystem"
|
||||||
|
rebuild: true
|
||||||
|
cache_key: "volume"
|
||||||
|
mount:
|
||||||
|
- target
|
||||||
|
- /usr/local/cargo/env
|
||||||
|
volumes:
|
||||||
|
- name: cache
|
||||||
|
path: /tmp/cache
|
||||||
|
depends_on:
|
||||||
|
- build-aarch64-unknown-linux-gnu
|
||||||
|
- build-x86_64-unknown-linux-gnu
|
||||||
|
- build-x86_64-pc-windows-gnu
|
||||||
|
- lint
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
- name: cache
|
||||||
|
host:
|
||||||
|
path: /var/lib/cache
|
1
.envrc
Normal file
1
.envrc
Normal file
|
@ -0,0 +1 @@
|
||||||
|
use flake
|
33
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
33
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
|
@ -0,0 +1,33 @@
|
||||||
|
---
|
||||||
|
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
Normal file
20
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal file
|
@ -0,0 +1,20 @@
|
||||||
|
---
|
||||||
|
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.
|
7
.github/dependabot.yml
vendored
Normal file
7
.github/dependabot.yml
vendored
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
# https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates
|
||||||
|
version: 2
|
||||||
|
updates:
|
||||||
|
- package-ecosystem: "cargo"
|
||||||
|
directory: "/"
|
||||||
|
schedule:
|
||||||
|
interval: "daily"
|
4
.gitignore
vendored
4
.gitignore
vendored
|
@ -1,2 +1,4 @@
|
||||||
/target
|
/target
|
||||||
/release
|
|
||||||
|
# nix
|
||||||
|
/result
|
||||||
|
|
128
CODE_OF_CONDUCT.md
Normal file
128
CODE_OF_CONDUCT.md
Normal file
|
@ -0,0 +1,128 @@
|
||||||
|
# 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.
|
2403
Cargo.lock
generated
2403
Cargo.lock
generated
File diff suppressed because it is too large
Load diff
31
Cargo.toml
31
Cargo.toml
|
@ -1,20 +1,25 @@
|
||||||
[package]
|
[package]
|
||||||
name = "empede-tide"
|
name = "empede"
|
||||||
version = "0.1.0"
|
description = "A web client for MPD"
|
||||||
|
version = "0.2.3"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
license = "MIT"
|
||||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
repository = "https://github.com/vijfhoek/empede"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
anyhow = "1.0.70"
|
anyhow = "1.0.70"
|
||||||
askama = "0.12.0"
|
askama = { version = "0.12.0", default-features = false, features = ["serde-json"] }
|
||||||
askama_tide = "0.15.0"
|
infer = { version = "0.15.0", default-features = false }
|
||||||
async-std = { version = "1.12.0", features = ["attributes"] }
|
percent-encoding = "2.2.0"
|
||||||
infer = "0.13.0"
|
|
||||||
mpdrs = "0.1.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"
|
||||||
tide = "0.16.0"
|
askama_actix = "0.14.0"
|
||||||
tide-tracing = "0.0.12"
|
tokio = { version = "1.35.1", features = ["full"] }
|
||||||
tracing = "0.1.37"
|
actix-web = "4.4.0"
|
||||||
tracing-subscriber = "0.3.17"
|
thiserror = "1.0.51"
|
||||||
|
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"
|
||||||
|
|
18
Dockerfile
Normal file
18
Dockerfile
Normal file
|
@ -0,0 +1,18 @@
|
||||||
|
FROM rust:alpine AS builder
|
||||||
|
WORKDIR /usr/src/empede
|
||||||
|
RUN apk add --no-cache build-base
|
||||||
|
COPY ./src ./src
|
||||||
|
COPY ./templates ./templates
|
||||||
|
COPY ./Cargo.toml ./Cargo.lock ./
|
||||||
|
RUN cargo install --path .
|
||||||
|
|
||||||
|
FROM alpine:latest
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=builder /usr/local/cargo/bin/empede ./
|
||||||
|
COPY ./static ./static
|
||||||
|
|
||||||
|
ARG MPD_HOST
|
||||||
|
ARG MPD_PORT
|
||||||
|
ARG EMPEDE_BIND
|
||||||
|
|
||||||
|
CMD ["./empede"]
|
22
README.md
22
README.md
|
@ -1,14 +1,34 @@
|
||||||
# Empede
|
# Empede
|
||||||
|
|
||||||
|
[![Drone (self-hosted)](https://img.shields.io/drone/build/_/empede?server=https%3A%2F%2Fci.sijman.nl)](https://ci.sijman.nl/_/empede)
|
||||||
|
[![Docker Image Size (latest semver)](https://img.shields.io/docker/image-size/vijfhoek/empede)](https://quay.io/repository/vijfhoek/empede)
|
||||||
|
[![Crates.io](https://img.shields.io/crates/v/empede)](https://crates.io/crates/empede)
|
||||||
|
|
||||||
**A web client for MPD.**
|
**A web client for MPD.**
|
||||||
|
|
||||||
![Screenshot](screenshots/screenshot.webp)
|
![Screenshot](screenshots/screenshot.webp)
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
Empede is configured using environment variables:
|
||||||
|
|
||||||
|
| Name | Default | Description |
|
||||||
|
| ---------------- | ------------ | --------------------------------- |
|
||||||
|
| **MPD_HOST** | localhost | MPD server host |
|
||||||
|
| **MPD_PORT** | 6600 | MPD server port |
|
||||||
|
| **MPD_PASSWORD** | | MPD server password |
|
||||||
|
| **EMPEDE_BIND** | 0.0.0.0:8080 | Address for Empede to bind to |
|
||||||
|
|
||||||
## Running
|
## Running
|
||||||
### Linux
|
### Linux
|
||||||
1. Download the [latest release](https://git.sijman.nl/_/empede/releases)
|
1. Download and extract the [latest release](https://git.sijman.nl/_/empede/releases)
|
||||||
2. Run `./empede` (To specify a host and port, run `MPD_HOST=ip MPD_PORT=6600 ./empede`)
|
2. Run `./empede` (To specify a host and port, run `MPD_HOST=ip MPD_PORT=6600 ./empede`)
|
||||||
3. Go to http://localhost:8080
|
3. Go to http://localhost:8080
|
||||||
|
|
||||||
|
### Windows
|
||||||
|
1. Download and extract the [latest release](https://git.sijman.nl/_/empede/releases)
|
||||||
|
3. Run `.\empede.exe` in a PowerShell (To specify a host and port, first set the `$env:MPD_HOST` and `$env:MPD_PORT` variables)
|
||||||
|
3. Go to http://localhost:8080
|
||||||
|
|
||||||
### Building from source
|
### Building from source
|
||||||
1. Make sure Rust is installed (https://rustup.rs/)
|
1. Make sure Rust is installed (https://rustup.rs/)
|
||||||
2. Run `cargo run` (To specify a host and port, run `MPD_HOST=ip MPD_PORT=6600 cargo run`)
|
2. Run `cargo run` (To specify a host and port, run `MPD_HOST=ip MPD_PORT=6600 cargo run`)
|
||||||
|
|
7
default.nix
Normal file
7
default.nix
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
(import (
|
||||||
|
fetchTarball {
|
||||||
|
url = "https://github.com/edolstra/flake-compat/archive/99f1c2157fba4bfe6211a321fd0ee43199025dbf.tar.gz";
|
||||||
|
sha256 = "0x2jn3vrawwv9xp15674wjz9pixwjyj3j771izayl962zziivbx2"; }
|
||||||
|
) {
|
||||||
|
src = ./.;
|
||||||
|
}).defaultNix
|
95
flake.lock
Normal file
95
flake.lock
Normal file
|
@ -0,0 +1,95 @@
|
||||||
|
{
|
||||||
|
"nodes": {
|
||||||
|
"naersk": {
|
||||||
|
"inputs": {
|
||||||
|
"nixpkgs": "nixpkgs"
|
||||||
|
},
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1679567394,
|
||||||
|
"narHash": "sha256-ZvLuzPeARDLiQUt6zSZFGOs+HZmE+3g4QURc8mkBsfM=",
|
||||||
|
"owner": "nix-community",
|
||||||
|
"repo": "naersk",
|
||||||
|
"rev": "88cd22380154a2c36799fe8098888f0f59861a15",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "nix-community",
|
||||||
|
"ref": "master",
|
||||||
|
"repo": "naersk",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"nixpkgs": {
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1683353485,
|
||||||
|
"narHash": "sha256-Skp5El3egmoXPiINWjnoW0ktVfB7PR/xc4F4bhD+BJY=",
|
||||||
|
"owner": "NixOS",
|
||||||
|
"repo": "nixpkgs",
|
||||||
|
"rev": "caf436a52b25164b71e0d48b671127ac2e2a5b75",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"id": "nixpkgs",
|
||||||
|
"type": "indirect"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"nixpkgs_2": {
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1683353485,
|
||||||
|
"narHash": "sha256-Skp5El3egmoXPiINWjnoW0ktVfB7PR/xc4F4bhD+BJY=",
|
||||||
|
"owner": "NixOS",
|
||||||
|
"repo": "nixpkgs",
|
||||||
|
"rev": "caf436a52b25164b71e0d48b671127ac2e2a5b75",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "NixOS",
|
||||||
|
"ref": "nixpkgs-unstable",
|
||||||
|
"repo": "nixpkgs",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"root": {
|
||||||
|
"inputs": {
|
||||||
|
"naersk": "naersk",
|
||||||
|
"nixpkgs": "nixpkgs_2",
|
||||||
|
"utils": "utils"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"systems": {
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1681028828,
|
||||||
|
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
|
||||||
|
"owner": "nix-systems",
|
||||||
|
"repo": "default",
|
||||||
|
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "nix-systems",
|
||||||
|
"repo": "default",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"utils": {
|
||||||
|
"inputs": {
|
||||||
|
"systems": "systems"
|
||||||
|
},
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1681202837,
|
||||||
|
"narHash": "sha256-H+Rh19JDwRtpVPAWp64F+rlEtxUWBAQW28eAi3SRSzg=",
|
||||||
|
"owner": "numtide",
|
||||||
|
"repo": "flake-utils",
|
||||||
|
"rev": "cfacdce06f30d2b68473a46042957675eebb3401",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "numtide",
|
||||||
|
"repo": "flake-utils",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"root": "root",
|
||||||
|
"version": 7
|
||||||
|
}
|
21
flake.nix
Normal file
21
flake.nix
Normal file
|
@ -0,0 +1,21 @@
|
||||||
|
{
|
||||||
|
inputs = {
|
||||||
|
naersk.url = "github:nix-community/naersk/master";
|
||||||
|
nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable";
|
||||||
|
utils.url = "github:numtide/flake-utils";
|
||||||
|
};
|
||||||
|
|
||||||
|
outputs = { self, nixpkgs, utils, naersk }:
|
||||||
|
utils.lib.eachDefaultSystem (system:
|
||||||
|
let
|
||||||
|
pkgs = import nixpkgs { inherit system; };
|
||||||
|
naersk-lib = pkgs.callPackage naersk { };
|
||||||
|
in
|
||||||
|
{
|
||||||
|
defaultPackage = naersk-lib.buildPackage ./.;
|
||||||
|
devShell = with pkgs; mkShell {
|
||||||
|
buildInputs = [ cargo rustc rustfmt pre-commit rustPackages.clippy ];
|
||||||
|
RUST_SRC_PATH = rustPlatform.rustLibSrc;
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
Binary file not shown.
Before Width: | Height: | Size: 138 KiB After Width: | Height: | Size: 54 KiB |
7
shell.nix
Normal file
7
shell.nix
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
(import (
|
||||||
|
fetchTarball {
|
||||||
|
url = "https://github.com/edolstra/flake-compat/archive/99f1c2157fba4bfe6211a321fd0ee43199025dbf.tar.gz";
|
||||||
|
sha256 = "0x2jn3vrawwv9xp15674wjz9pixwjyj3j771izayl962zziivbx2"; }
|
||||||
|
) {
|
||||||
|
src = ./.;
|
||||||
|
}).shellNix
|
6
src/crate_version.rs
Normal file
6
src/crate_version.rs
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
#[macro_export]
|
||||||
|
macro_rules! crate_version {
|
||||||
|
() => {
|
||||||
|
env!("CARGO_PKG_VERSION")
|
||||||
|
};
|
||||||
|
}
|
228
src/main.rs
228
src/main.rs
|
@ -1,201 +1,43 @@
|
||||||
use std::path::Path;
|
use actix_web::{middleware::Logger, web, App, HttpServer};
|
||||||
|
|
||||||
use anyhow::anyhow;
|
|
||||||
use askama::Template;
|
|
||||||
use async_std::prelude::*;
|
|
||||||
use async_std::{
|
|
||||||
io::{BufReader, WriteExt},
|
|
||||||
net::TcpStream,
|
|
||||||
};
|
|
||||||
use serde::Deserialize;
|
|
||||||
|
|
||||||
|
mod crate_version;
|
||||||
mod mpd;
|
mod mpd;
|
||||||
|
mod routes;
|
||||||
|
|
||||||
#[derive(Template)]
|
#[actix_web::main]
|
||||||
#[template(path = "index.html")]
|
async fn main() -> std::io::Result<()> {
|
||||||
struct IndexTemplate;
|
let bind = std::env::var("EMPEDE_BIND").unwrap_or("0.0.0.0:8080".into());
|
||||||
|
let (host, port) = bind.split_once(':').unwrap();
|
||||||
|
|
||||||
#[derive(Deserialize, Default)]
|
env_logger::init_from_env(env_logger::Env::new().default_filter_or("info"));
|
||||||
#[serde(default)]
|
|
||||||
struct IndexQuery {
|
|
||||||
path: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn get_index(_req: tide::Request<()>) -> tide::Result {
|
HttpServer::new(|| {
|
||||||
Ok(askama_tide::into_response(&IndexTemplate))
|
App::new().wrap(Logger::default()).service(
|
||||||
}
|
web::scope("")
|
||||||
|
.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?;
|
||||||
|
|
||||||
#[derive(Template)]
|
|
||||||
#[template(path = "queue.html")]
|
|
||||||
struct QueueTemplate {
|
|
||||||
queue: Vec<mpd::QueueItem>,
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn get_queue(_req: tide::Request<()>) -> tide::Result {
|
|
||||||
let queue = mpd::playlist()?;
|
|
||||||
let template = QueueTemplate { queue };
|
|
||||||
Ok(template.into())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Template)]
|
|
||||||
#[template(path = "player.html")]
|
|
||||||
struct CurrentTemplate {
|
|
||||||
song: Option<mpdrs::Song>,
|
|
||||||
name: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn get_player(_req: tide::Request<()>) -> tide::Result {
|
|
||||||
let mut mpd = mpd::connect()?;
|
|
||||||
let song = mpd.currentsong()?;
|
|
||||||
|
|
||||||
let mut template = CurrentTemplate {
|
|
||||||
song: song.clone(),
|
|
||||||
name: None,
|
|
||||||
};
|
|
||||||
|
|
||||||
if let Some(song) = song {
|
|
||||||
let name = song.title.unwrap_or(song.file);
|
|
||||||
template.name = Some(name);
|
|
||||||
}
|
|
||||||
|
|
||||||
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)]
|
|
||||||
struct PostQueueQuery {
|
|
||||||
path: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn post_queue(req: tide::Request<()>) -> tide::Result {
|
|
||||||
let query: PostQueueQuery = req.query()?;
|
|
||||||
mpd::connect()?.add(&query.path)?;
|
|
||||||
Ok("".into())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn delete_queue(_req: tide::Request<()>) -> tide::Result {
|
|
||||||
mpd::connect()?.clear()?;
|
|
||||||
Ok("".into())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn post_play(_req: tide::Request<()>) -> tide::Result {
|
|
||||||
mpd::connect()?.play()?;
|
|
||||||
Ok("".into())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn post_pause(_req: tide::Request<()>) -> tide::Result {
|
|
||||||
mpd::connect()?.pause(true)?;
|
|
||||||
Ok("".into())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn post_previous(_req: tide::Request<()>) -> tide::Result {
|
|
||||||
mpd::connect()?.prev()?;
|
|
||||||
Ok("".into())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn post_next(_req: tide::Request<()>) -> tide::Result {
|
|
||||||
mpd::connect()?.next()?;
|
|
||||||
Ok("".into())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn get_art(req: tide::Request<()>) -> tide::Result {
|
|
||||||
let query: IndexQuery = req.query()?;
|
|
||||||
let resp = if let Ok(art) = mpd::connect()?.albumart(&query.path) {
|
|
||||||
let mime = infer::get(&art)
|
|
||||||
.map(|k| k.mime_type())
|
|
||||||
.unwrap_or("application/octet-stream");
|
|
||||||
|
|
||||||
tide::Response::builder(tide::StatusCode::Ok)
|
|
||||||
.body(art)
|
|
||||||
.content_type(mime)
|
|
||||||
.header("cache-control", "max-age=3600")
|
|
||||||
} else {
|
|
||||||
tide::Response::builder(tide::StatusCode::NotFound)
|
|
||||||
};
|
|
||||||
Ok(resp.into())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn sse(_req: tide::Request<()>, sender: tide::sse::Sender) -> tide::Result<()> {
|
|
||||||
// Needs to be async and all async mpd libraries suck
|
|
||||||
let mut stream = TcpStream::connect(mpd::host()).await?;
|
|
||||||
let mut reader = BufReader::new(stream.clone());
|
|
||||||
|
|
||||||
// skip OK MPD line
|
|
||||||
// TODO check if it is indeed OK
|
|
||||||
let mut buffer = String::new();
|
|
||||||
reader.read_line(&mut buffer).await?;
|
|
||||||
|
|
||||||
// Update everything on connect
|
|
||||||
sender.send("playlist", "", None).await?;
|
|
||||||
sender.send("player", "", None).await?;
|
|
||||||
|
|
||||||
loop {
|
|
||||||
stream.write_all(b"idle playlist player database\n").await?;
|
|
||||||
|
|
||||||
loop {
|
|
||||||
buffer.clear();
|
|
||||||
reader.read_line(&mut buffer).await?;
|
|
||||||
if buffer == "OK\n" {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
let (_, changed) = buffer
|
|
||||||
.trim_end()
|
|
||||||
.split_once(": ")
|
|
||||||
.ok_or(anyhow!("unexpected response from MPD"))?;
|
|
||||||
sender.send(changed, "", None).await?;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_std::main]
|
|
||||||
async fn main() -> tide::Result<()> {
|
|
||||||
tracing_subscriber::fmt()
|
|
||||||
.with_max_level(tracing::Level::INFO)
|
|
||||||
.init();
|
|
||||||
|
|
||||||
let mut app = tide::new();
|
|
||||||
app.with(tide_tracing::TraceMiddleware::new());
|
|
||||||
|
|
||||||
app.at("/").get(get_index);
|
|
||||||
app.at("/queue").get(get_queue);
|
|
||||||
app.at("/player").get(get_player);
|
|
||||||
app.at("/art").get(get_art);
|
|
||||||
app.at("/browser").get(get_browser);
|
|
||||||
|
|
||||||
app.at("/sse").get(tide::sse::endpoint(sse));
|
|
||||||
|
|
||||||
app.at("/queue").post(post_queue);
|
|
||||||
app.at("/queue").delete(delete_queue);
|
|
||||||
|
|
||||||
app.at("/play").post(post_play);
|
|
||||||
app.at("/pause").post(post_pause);
|
|
||||||
app.at("/previous").post(post_previous);
|
|
||||||
app.at("/next").post(post_next);
|
|
||||||
|
|
||||||
app.at("/static").serve_dir("static/")?;
|
|
||||||
|
|
||||||
app.listen("0.0.0.0:8080").await?;
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
412
src/mpd.rs
412
src/mpd.rs
|
@ -1,77 +1,31 @@
|
||||||
use std::borrow::Cow;
|
use std::collections::HashMap;
|
||||||
|
|
||||||
use mpdrs::lsinfo::LsInfoResponse;
|
use anyhow::anyhow;
|
||||||
|
use tokio::{
|
||||||
|
io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufStream},
|
||||||
|
net::TcpStream,
|
||||||
|
sync::{Mutex, MutexGuard, OnceCell},
|
||||||
|
};
|
||||||
|
|
||||||
pub(crate) fn host() -> String {
|
pub fn host() -> String {
|
||||||
let host = std::env::var("MPD_HOST").unwrap_or("localhost".to_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());
|
let port = std::env::var("MPD_PORT").unwrap_or("6600".to_string());
|
||||||
format!("{host}:{port}")
|
format!("{host}:{port}")
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn connect() -> Result<mpdrs::Client, mpdrs::error::Error> {
|
pub struct QueueItem {
|
||||||
mpdrs::Client::connect(host())
|
pub id: u32,
|
||||||
|
pub position: i32,
|
||||||
|
pub file: String,
|
||||||
|
pub title: String,
|
||||||
|
pub artist: Option<String>,
|
||||||
|
pub playing: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn ls(path: &str) -> anyhow::Result<Vec<Entry>> {
|
#[derive(Debug)]
|
||||||
let info = connect()?.lsinfo(path)?;
|
pub enum Entry {
|
||||||
|
|
||||||
fn filename(path: &str) -> Cow<str> {
|
|
||||||
std::path::Path::new(path)
|
|
||||||
.file_name()
|
|
||||||
.map(|x| x.to_string_lossy())
|
|
||||||
.unwrap_or(Cow::Borrowed("n/a"))
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(info
|
|
||||||
.iter()
|
|
||||||
.map(|e| match e {
|
|
||||||
LsInfoResponse::Song(song) => Entry::Song {
|
|
||||||
name: song.title.as_ref().unwrap_or(&song.file).clone(),
|
|
||||||
artist: song.artist.clone().unwrap_or(String::new()),
|
|
||||||
path: song.file.clone(),
|
|
||||||
},
|
|
||||||
|
|
||||||
LsInfoResponse::Directory { path, .. } => Entry::Directory {
|
|
||||||
name: filename(path).to_string(),
|
|
||||||
path: path.to_string(),
|
|
||||||
},
|
|
||||||
|
|
||||||
LsInfoResponse::Playlist { path, .. } => Entry::Playlist {
|
|
||||||
name: filename(path).to_string(),
|
|
||||||
path: path.to_string(),
|
|
||||||
},
|
|
||||||
})
|
|
||||||
.collect())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) struct QueueItem {
|
|
||||||
pub(crate) file: String,
|
|
||||||
pub(crate) title: String,
|
|
||||||
pub(crate) artist: Option<String>,
|
|
||||||
pub(crate) playing: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn playlist() -> anyhow::Result<Vec<QueueItem>> {
|
|
||||||
let mut client = connect()?;
|
|
||||||
|
|
||||||
let current = client.status()?.song;
|
|
||||||
|
|
||||||
let queue = client
|
|
||||||
.queue()?
|
|
||||||
.into_iter()
|
|
||||||
.map(|song| QueueItem {
|
|
||||||
file: song.file.clone(),
|
|
||||||
title: song.title.as_ref().unwrap_or(&song.file).clone(),
|
|
||||||
artist: song.artist.clone(),
|
|
||||||
playing: current == song.place,
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
Ok(queue)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) enum Entry {
|
|
||||||
Song {
|
Song {
|
||||||
|
track: Option<i32>,
|
||||||
name: String,
|
name: String,
|
||||||
artist: String,
|
artist: String,
|
||||||
path: String,
|
path: String,
|
||||||
|
@ -85,3 +39,333 @@ pub(crate) enum Entry {
|
||||||
path: String,
|
path: String,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct Mpd {
|
||||||
|
bufstream: Option<BufStream<TcpStream>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub static INSTANCE: OnceCell<Mutex<Mpd>> = OnceCell::const_new();
|
||||||
|
|
||||||
|
pub async fn get_instance() -> MutexGuard<'static, Mpd> {
|
||||||
|
INSTANCE
|
||||||
|
.get_or_init(|| async {
|
||||||
|
let mut mpd = Mpd::new();
|
||||||
|
mpd.connect().await.unwrap();
|
||||||
|
Mutex::from(mpd)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.lock()
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn command(command: &str) -> anyhow::Result<CommandResult> {
|
||||||
|
get_instance().await.command(command).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct CommandResult {
|
||||||
|
properties: Vec<(String, String)>,
|
||||||
|
binary: Option<Vec<u8>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CommandResult {
|
||||||
|
pub fn new(properties: Vec<(String, String)>) -> Self {
|
||||||
|
Self {
|
||||||
|
properties,
|
||||||
|
binary: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn new_binary(properties: Vec<(String, String)>, binary: Vec<u8>) -> Self {
|
||||||
|
Self {
|
||||||
|
properties,
|
||||||
|
binary: Some(binary),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn into_hashmap(self) -> HashMap<String, String> {
|
||||||
|
self.properties.into_iter().collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn into_hashmaps(self, split_at: &[&str]) -> Vec<HashMap<String, String>> {
|
||||||
|
let mut output = Vec::new();
|
||||||
|
let mut current = None;
|
||||||
|
|
||||||
|
for (key, value) in self.properties {
|
||||||
|
if split_at.contains(&key.as_str()) {
|
||||||
|
if let Some(current) = current {
|
||||||
|
output.push(current);
|
||||||
|
}
|
||||||
|
current = Some(HashMap::new());
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(current) = current.as_mut() {
|
||||||
|
current.insert(key, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(current) = current {
|
||||||
|
output.push(current);
|
||||||
|
}
|
||||||
|
|
||||||
|
output
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Mpd {
|
||||||
|
pub fn escape_str(s: &str) -> String {
|
||||||
|
s.replace('\"', "\\\"").replace('\'', "\\'")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self { bufstream: None }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn connect(&mut self) -> anyhow::Result<()> {
|
||||||
|
let stream = TcpStream::connect(host()).await?;
|
||||||
|
let mut bufstream = BufStream::new(stream);
|
||||||
|
|
||||||
|
// skip OK MPD line
|
||||||
|
// TODO check if it is indeed OK
|
||||||
|
let mut buffer = String::new();
|
||||||
|
bufstream.read_line(&mut buffer).await?;
|
||||||
|
|
||||||
|
let password = std::env::var("MPD_PASSWORD").unwrap_or_default();
|
||||||
|
if !password.is_empty() {
|
||||||
|
let password = Self::escape_str(&password);
|
||||||
|
bufstream
|
||||||
|
.write_all(format!("password \"{password}\"\n").as_bytes())
|
||||||
|
.await?;
|
||||||
|
bufstream.flush().await?;
|
||||||
|
bufstream.read_line(&mut buffer).await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
bufstream
|
||||||
|
.write_all("binarylimit 1048576\n".as_bytes())
|
||||||
|
.await?;
|
||||||
|
bufstream.flush().await?;
|
||||||
|
bufstream.read_line(&mut buffer).await?;
|
||||||
|
|
||||||
|
self.bufstream = Some(bufstream);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn read_binary_data(&mut self, size: usize) -> anyhow::Result<Vec<u8>> {
|
||||||
|
let mut binary = vec![0u8; size];
|
||||||
|
self.bufstream
|
||||||
|
.as_mut()
|
||||||
|
.unwrap()
|
||||||
|
.read_exact(&mut binary)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let mut buffer = String::new();
|
||||||
|
|
||||||
|
// Skip the newline after the binary data
|
||||||
|
self.bufstream
|
||||||
|
.as_mut()
|
||||||
|
.unwrap()
|
||||||
|
.read_line(&mut buffer)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// Skip the "OK" after the binary data
|
||||||
|
// TODO Check if actually OK
|
||||||
|
self.bufstream
|
||||||
|
.as_mut()
|
||||||
|
.unwrap()
|
||||||
|
.read_line(&mut buffer)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(binary)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn command(&mut self, command: &str) -> anyhow::Result<CommandResult> {
|
||||||
|
let mut properties = Vec::new();
|
||||||
|
|
||||||
|
'retry: loop {
|
||||||
|
self.bufstream
|
||||||
|
.as_mut()
|
||||||
|
.unwrap()
|
||||||
|
.write_all(format!("{command}\n").as_bytes())
|
||||||
|
.await?;
|
||||||
|
self.bufstream.as_mut().unwrap().flush().await?;
|
||||||
|
|
||||||
|
let mut buffer = String::new();
|
||||||
|
break 'retry (loop {
|
||||||
|
buffer.clear();
|
||||||
|
self.bufstream
|
||||||
|
.as_mut()
|
||||||
|
.unwrap()
|
||||||
|
.read_line(&mut buffer)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if let Some((key, value)) = buffer.split_once(": ") {
|
||||||
|
let value = value.trim_end();
|
||||||
|
properties.push((key.to_string(), value.to_string()));
|
||||||
|
|
||||||
|
if key == "binary" {
|
||||||
|
let binary = self.read_binary_data(value.parse()?).await?;
|
||||||
|
break Ok(CommandResult::new_binary(properties, binary));
|
||||||
|
}
|
||||||
|
} else if buffer.starts_with("OK") {
|
||||||
|
break Ok(CommandResult::new(properties));
|
||||||
|
} else if buffer.starts_with("ACK") {
|
||||||
|
break Err(anyhow!(buffer));
|
||||||
|
} else {
|
||||||
|
println!("Unexpected MPD response '{buffer}'");
|
||||||
|
self.connect().await.unwrap();
|
||||||
|
continue 'retry;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn command_binary(&mut self, command: &str) -> anyhow::Result<CommandResult> {
|
||||||
|
let mut buffer = Vec::new();
|
||||||
|
|
||||||
|
loop {
|
||||||
|
let command = format!("{} {}", command, buffer.len());
|
||||||
|
let result = self.command(&command).await?;
|
||||||
|
|
||||||
|
if let Some(mut binary) = result.binary {
|
||||||
|
if !binary.is_empty() {
|
||||||
|
buffer.append(&mut binary);
|
||||||
|
} else {
|
||||||
|
return Ok(CommandResult::new_binary(result.properties, buffer));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return Ok(CommandResult::new(result.properties));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn clear(&mut self) -> anyhow::Result<()> {
|
||||||
|
self.command("clear").await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn add(&mut self, path: &str) -> anyhow::Result<()> {
|
||||||
|
let path = Self::escape_str(path);
|
||||||
|
self.command(&format!("add \"{path}\"")).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn add_position(&mut self, path: &str, position: &str) -> anyhow::Result<()> {
|
||||||
|
let path = Self::escape_str(path);
|
||||||
|
let position = Self::escape_str(position);
|
||||||
|
self.command(&format!(r#"add "{path}" "{position}""#))
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn play(&mut self, position: Option<&str>) -> anyhow::Result<()> {
|
||||||
|
let command = match position {
|
||||||
|
Some(position) => format!(r#"play "{position}""#),
|
||||||
|
None => "play".into(),
|
||||||
|
};
|
||||||
|
self.command(&command).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn idle(&mut self, systems: &[&str]) -> anyhow::Result<Vec<String>> {
|
||||||
|
let systems = systems.join(" ");
|
||||||
|
let result = self.command(&format!("idle {systems}")).await?;
|
||||||
|
let changed = result
|
||||||
|
.properties
|
||||||
|
.iter()
|
||||||
|
.filter(|(key, _)| key == "changed")
|
||||||
|
.map(|(_, value)| value.clone())
|
||||||
|
.collect();
|
||||||
|
Ok(changed)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn albumart(&mut self, path: &str) -> anyhow::Result<Vec<u8>> {
|
||||||
|
let path = Self::escape_str(path);
|
||||||
|
let result = self
|
||||||
|
.command_binary(&format!(r#"albumart "{path}""#))
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
match result.binary {
|
||||||
|
Some(binary) => Ok(binary),
|
||||||
|
None => Err(anyhow!("no album art")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn readpicture(&mut self, path: &str) -> anyhow::Result<Vec<u8>> {
|
||||||
|
let path = Self::escape_str(path);
|
||||||
|
let result = self
|
||||||
|
.command_binary(&format!(r#"readpicture "{path}""#))
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
match result.binary {
|
||||||
|
Some(binary) => Ok(binary),
|
||||||
|
None => Err(anyhow!("no album art")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::manual_map)]
|
||||||
|
pub async fn ls(&mut self, path: &str) -> anyhow::Result<Vec<Entry>> {
|
||||||
|
fn get_filename(path: &str) -> String {
|
||||||
|
std::path::Path::new(path)
|
||||||
|
.file_name()
|
||||||
|
.map(|x| x.to_string_lossy().to_string())
|
||||||
|
.unwrap_or("n/a".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
let path = Self::escape_str(path);
|
||||||
|
let result = self
|
||||||
|
.command(&format!(r#"lsinfo "{path}""#))
|
||||||
|
.await?
|
||||||
|
.into_hashmaps(&["file", "directory", "playlist"]);
|
||||||
|
|
||||||
|
let files = result
|
||||||
|
.iter()
|
||||||
|
.flat_map(|prop| {
|
||||||
|
if let Some(file) = prop.get("file") {
|
||||||
|
Some(Entry::Song {
|
||||||
|
track: prop.get("Track").and_then(|track| track.parse().ok()),
|
||||||
|
name: prop.get("Title").unwrap_or(&get_filename(file)).clone(),
|
||||||
|
artist: prop.get("Artist").unwrap_or(&String::new()).clone(),
|
||||||
|
path: file.to_string(),
|
||||||
|
})
|
||||||
|
} else if let Some(file) = prop.get("directory") {
|
||||||
|
Some(Entry::Directory {
|
||||||
|
name: get_filename(file),
|
||||||
|
path: file.to_string(),
|
||||||
|
})
|
||||||
|
} else if let Some(file) = prop.get("playlist") {
|
||||||
|
Some(Entry::Playlist {
|
||||||
|
name: get_filename(file),
|
||||||
|
path: file.to_string(),
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Ok(files)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn playlist(&mut self) -> anyhow::Result<Vec<QueueItem>> {
|
||||||
|
let status = self.command("status").await?.into_hashmap();
|
||||||
|
let current_songid = status.get("songid");
|
||||||
|
|
||||||
|
let playlistinfo = self.command("playlistinfo").await?;
|
||||||
|
let queue = playlistinfo.into_hashmaps(&["file"]);
|
||||||
|
|
||||||
|
let queue = queue
|
||||||
|
.iter()
|
||||||
|
.map(|song| QueueItem {
|
||||||
|
id: song["Id"].parse().unwrap(),
|
||||||
|
position: song["Pos"].parse().unwrap(),
|
||||||
|
file: song["file"].clone(),
|
||||||
|
title: song.get("Title").unwrap_or(&song["file"]).clone(),
|
||||||
|
artist: song.get("Artist").cloned(),
|
||||||
|
playing: current_songid == song.get("Id"),
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Ok(queue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
42
src/routes/art.rs
Normal file
42
src/routes/art.rs
Normal file
|
@ -0,0 +1,42 @@
|
||||||
|
use crate::mpd;
|
||||||
|
use actix_web::{
|
||||||
|
get,
|
||||||
|
http::header::{self, CacheDirective},
|
||||||
|
web, HttpResponse, Responder,
|
||||||
|
};
|
||||||
|
use percent_encoding::percent_decode_str;
|
||||||
|
use serde::Deserialize;
|
||||||
|
|
||||||
|
#[derive(Deserialize, Default)]
|
||||||
|
#[serde(default)]
|
||||||
|
struct ArtQuery {
|
||||||
|
path: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[get("/art")]
|
||||||
|
pub async fn get_art(query: web::Query<ArtQuery>) -> impl Responder {
|
||||||
|
let path = percent_decode_str(&query.path).decode_utf8_lossy();
|
||||||
|
let mut mpd = mpd::get_instance().await;
|
||||||
|
|
||||||
|
if let Ok(art) = mpd.albumart(&path).await {
|
||||||
|
let mime = infer::get(&art)
|
||||||
|
.map(|k| k.mime_type())
|
||||||
|
.unwrap_or("application/octet-stream");
|
||||||
|
|
||||||
|
HttpResponse::Ok()
|
||||||
|
.content_type(mime)
|
||||||
|
.append_header(header::CacheControl(vec![CacheDirective::MaxAge(3600)]))
|
||||||
|
.body(art)
|
||||||
|
} else if let Ok(art) = mpd.readpicture(&path).await {
|
||||||
|
let mime = infer::get(&art)
|
||||||
|
.map(|k| k.mime_type())
|
||||||
|
.unwrap_or("application/octet-stream");
|
||||||
|
|
||||||
|
HttpResponse::Ok()
|
||||||
|
.content_type(mime)
|
||||||
|
.append_header(header::CacheControl(vec![CacheDirective::MaxAge(3600)]))
|
||||||
|
.body(art)
|
||||||
|
} else {
|
||||||
|
HttpResponse::NotFound().finish()
|
||||||
|
}
|
||||||
|
}
|
34
src/routes/browser.rs
Normal file
34
src/routes/browser.rs
Normal file
|
@ -0,0 +1,34 @@
|
||||||
|
use crate::mpd;
|
||||||
|
use actix_web::{get, web, Responder};
|
||||||
|
use askama::Template;
|
||||||
|
use percent_encoding::percent_decode_str;
|
||||||
|
use serde::Deserialize;
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
#[derive(Template)]
|
||||||
|
#[template(path = "browser.html")]
|
||||||
|
struct BrowserTemplate {
|
||||||
|
path: Vec<String>,
|
||||||
|
entries: Vec<mpd::Entry>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, Default)]
|
||||||
|
#[serde(default)]
|
||||||
|
struct BrowserQuery {
|
||||||
|
path: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[get("/browser")]
|
||||||
|
pub async fn get_browser(query: web::Query<BrowserQuery>) -> impl Responder {
|
||||||
|
let path = percent_decode_str(&query.path).decode_utf8_lossy();
|
||||||
|
let mut mpd = mpd::get_instance().await;
|
||||||
|
let entries = mpd.ls(&path).await.unwrap();
|
||||||
|
|
||||||
|
BrowserTemplate {
|
||||||
|
path: Path::new(&*path)
|
||||||
|
.iter()
|
||||||
|
.map(|s| s.to_string_lossy().to_string())
|
||||||
|
.collect(),
|
||||||
|
entries,
|
||||||
|
}
|
||||||
|
}
|
76
src/routes/controls.rs
Normal file
76
src/routes/controls.rs
Normal file
|
@ -0,0 +1,76 @@
|
||||||
|
use actix_web::{post, web, HttpResponse, Responder};
|
||||||
|
use serde::Deserialize;
|
||||||
|
|
||||||
|
use crate::mpd;
|
||||||
|
|
||||||
|
async fn toggle_setting(setting: &str) -> anyhow::Result<()> {
|
||||||
|
let mut mpd = mpd::get_instance().await;
|
||||||
|
|
||||||
|
let status = mpd.command("status").await?.into_hashmap();
|
||||||
|
let value = status[setting] == "1";
|
||||||
|
|
||||||
|
mpd.command(&format!("{} {}", setting, if value { 0 } else { 1 }))
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct PostPlayQuery {
|
||||||
|
#[serde(default)]
|
||||||
|
position: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[post("/play")]
|
||||||
|
pub async fn post_play(query: web::Query<PostPlayQuery>) -> impl Responder {
|
||||||
|
let mut mpd = mpd::get_instance().await;
|
||||||
|
mpd.play(query.position.as_deref()).await.unwrap();
|
||||||
|
HttpResponse::NoContent()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[post("/pause")]
|
||||||
|
pub async fn post_pause() -> impl Responder {
|
||||||
|
mpd::command("pause 1").await.unwrap();
|
||||||
|
HttpResponse::NoContent()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[post("/previous")]
|
||||||
|
pub async fn post_previous() -> impl Responder {
|
||||||
|
mpd::command("previous").await.unwrap();
|
||||||
|
HttpResponse::NoContent()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[post("/next")]
|
||||||
|
pub async fn post_next() -> impl Responder {
|
||||||
|
mpd::command("next").await.unwrap();
|
||||||
|
HttpResponse::NoContent()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[post("/consume")]
|
||||||
|
pub async fn post_consume() -> impl Responder {
|
||||||
|
toggle_setting("consume").await.unwrap();
|
||||||
|
HttpResponse::NoContent()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[post("/random")]
|
||||||
|
pub async fn post_random() -> impl Responder {
|
||||||
|
toggle_setting("random").await.unwrap();
|
||||||
|
HttpResponse::NoContent()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[post("/repeat")]
|
||||||
|
pub async fn post_repeat() -> impl Responder {
|
||||||
|
toggle_setting("repeat").await.unwrap();
|
||||||
|
HttpResponse::NoContent()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[post("/shuffle")]
|
||||||
|
pub async fn post_shuffle() -> impl Responder {
|
||||||
|
mpd::command("shuffle").await.unwrap();
|
||||||
|
HttpResponse::NoContent()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[post("/single")]
|
||||||
|
pub async fn post_single() -> impl Responder {
|
||||||
|
toggle_setting("single").await.unwrap();
|
||||||
|
HttpResponse::NoContent()
|
||||||
|
}
|
19
src/routes/index.rs
Normal file
19
src/routes/index.rs
Normal file
|
@ -0,0 +1,19 @@
|
||||||
|
use crate::crate_version;
|
||||||
|
use actix_web::{get, Responder};
|
||||||
|
use askama::Template;
|
||||||
|
use serde::Deserialize;
|
||||||
|
|
||||||
|
#[derive(Template)]
|
||||||
|
#[template(path = "index.html")]
|
||||||
|
struct IndexTemplate;
|
||||||
|
|
||||||
|
#[derive(Deserialize, Default)]
|
||||||
|
#[serde(default)]
|
||||||
|
struct IndexQuery {
|
||||||
|
path: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[get("/")]
|
||||||
|
pub async fn get_index() -> impl Responder {
|
||||||
|
IndexTemplate
|
||||||
|
}
|
7
src/routes/mod.rs
Normal file
7
src/routes/mod.rs
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
pub mod art;
|
||||||
|
pub mod browser;
|
||||||
|
pub mod controls;
|
||||||
|
pub mod index;
|
||||||
|
pub mod player;
|
||||||
|
pub mod queue;
|
||||||
|
pub mod sse;
|
57
src/routes/player.rs
Normal file
57
src/routes/player.rs
Normal file
|
@ -0,0 +1,57 @@
|
||||||
|
use crate::mpd;
|
||||||
|
use actix_web::{get, Responder};
|
||||||
|
use askama::Template;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
#[derive(Template)]
|
||||||
|
#[template(path = "player.html")]
|
||||||
|
struct PlayerTemplate {
|
||||||
|
song: Option<HashMap<String, String>>,
|
||||||
|
name: Option<String>,
|
||||||
|
state: String,
|
||||||
|
consume: bool,
|
||||||
|
random: bool,
|
||||||
|
repeat: bool,
|
||||||
|
single: bool,
|
||||||
|
elapsed: f32,
|
||||||
|
duration: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[get("/player")]
|
||||||
|
pub async fn get_player() -> impl Responder {
|
||||||
|
let mut mpd = mpd::get_instance().await;
|
||||||
|
let song = mpd.command("currentsong").await.unwrap().into_hashmap();
|
||||||
|
let status = mpd.command("status").await.unwrap().into_hashmap();
|
||||||
|
|
||||||
|
let elapsed = status
|
||||||
|
.get("elapsed")
|
||||||
|
.and_then(|e| e.parse().ok())
|
||||||
|
.unwrap_or(0.0);
|
||||||
|
let duration = status
|
||||||
|
.get("duration")
|
||||||
|
.and_then(|e| e.parse().ok())
|
||||||
|
.unwrap_or(1.0);
|
||||||
|
|
||||||
|
let mut template = PlayerTemplate {
|
||||||
|
song: if song.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(song.clone())
|
||||||
|
},
|
||||||
|
name: None,
|
||||||
|
state: status["state"].clone(),
|
||||||
|
consume: status["consume"] == "1",
|
||||||
|
random: status["random"] == "1",
|
||||||
|
repeat: status["repeat"] == "1",
|
||||||
|
single: status["single"] == "1",
|
||||||
|
elapsed,
|
||||||
|
duration,
|
||||||
|
};
|
||||||
|
|
||||||
|
if !song.is_empty() {
|
||||||
|
let name = song.get("Title").unwrap_or(&song["file"]).to_string();
|
||||||
|
template.name = Some(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
template
|
||||||
|
}
|
84
src/routes/queue.rs
Normal file
84
src/routes/queue.rs
Normal file
|
@ -0,0 +1,84 @@
|
||||||
|
use crate::mpd;
|
||||||
|
use actix_web::{delete, get, post, web, HttpResponse, Responder};
|
||||||
|
use askama::Template;
|
||||||
|
use percent_encoding::percent_decode_str;
|
||||||
|
use serde::Deserialize;
|
||||||
|
|
||||||
|
#[derive(Template)]
|
||||||
|
#[template(path = "queue.html")]
|
||||||
|
struct QueueTemplate {
|
||||||
|
queue: Vec<mpd::QueueItem>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[get("/queue")]
|
||||||
|
pub async fn get_queue() -> impl Responder {
|
||||||
|
let mut mpd = mpd::get_instance().await;
|
||||||
|
let queue = mpd.playlist().await.unwrap();
|
||||||
|
QueueTemplate { queue }
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct PostQueueQuery {
|
||||||
|
path: String,
|
||||||
|
#[serde(default)]
|
||||||
|
replace: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
next: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
play: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[post("/queue")]
|
||||||
|
pub async fn post_queue(query: web::Query<PostQueueQuery>) -> impl Responder {
|
||||||
|
let path = percent_decode_str(&query.path).decode_utf8_lossy();
|
||||||
|
let mut mpd = mpd::get_instance().await;
|
||||||
|
|
||||||
|
if query.replace {
|
||||||
|
mpd.clear().await.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
if query.next {
|
||||||
|
mpd.add_position(&path, "+0").await.unwrap();
|
||||||
|
} else {
|
||||||
|
mpd.add(&path).await.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
if query.play {
|
||||||
|
mpd.play(None).await.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
HttpResponse::NoContent()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct DeleteQueueQuery {
|
||||||
|
#[serde(default)]
|
||||||
|
id: Option<u32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[delete("/queue")]
|
||||||
|
pub async fn delete_queue(query: web::Query<DeleteQueueQuery>) -> impl Responder {
|
||||||
|
let mut mpd = mpd::get_instance().await;
|
||||||
|
if let Some(id) = query.id {
|
||||||
|
mpd.command(&format!("deleteid {id}")).await.unwrap();
|
||||||
|
} else {
|
||||||
|
mpd.command("clear").await.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
HttpResponse::NoContent()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, Debug)]
|
||||||
|
struct UpdateQueueBody {
|
||||||
|
from: u32,
|
||||||
|
to: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[post("/queue/move")]
|
||||||
|
pub async fn post_queue_move(body: web::Json<UpdateQueueBody>) -> impl Responder {
|
||||||
|
let mut mpd = mpd::get_instance().await;
|
||||||
|
mpd.command(&format!("move {} {}", body.from, body.to))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
HttpResponse::NoContent()
|
||||||
|
}
|
33
src/routes/sse.rs
Normal file
33
src/routes/sse.rs
Normal file
|
@ -0,0 +1,33 @@
|
||||||
|
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))
|
||||||
|
}
|
BIN
static/placeholder.webp
Normal file
BIN
static/placeholder.webp
Normal file
Binary file not shown.
After Width: | Height: | Size: 5.4 KiB |
192
static/style.css
192
static/style.css
|
@ -11,16 +11,41 @@ html {
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
font-family: sans;
|
font-family: sans-serif;
|
||||||
background-color: #112;
|
background-color: #112;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
display: flex;
|
display: flex;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
@media (prefers-contrast: more) {
|
||||||
|
body {
|
||||||
|
background-color: black;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
body > div {
|
button {
|
||||||
padding: 1rem;
|
background-color: transparent;
|
||||||
|
border: none;
|
||||||
|
color: inherit;
|
||||||
|
font-weight: bold;
|
||||||
|
display: flex;
|
||||||
|
line-height: 24px;
|
||||||
|
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 {
|
||||||
|
margin-right: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.active {
|
||||||
|
color: #99f;
|
||||||
}
|
}
|
||||||
|
|
||||||
.browser {
|
.browser {
|
||||||
|
@ -28,6 +53,7 @@ body > div {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-flow: column;
|
flex-flow: column;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
a {
|
a {
|
||||||
|
@ -35,6 +61,14 @@ a {
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
@media (prefers-contrast: more) {
|
||||||
|
a,
|
||||||
|
[role=button],
|
||||||
|
[role=link] {
|
||||||
|
color: #99f;
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
ul {
|
ul {
|
||||||
list-style: none;
|
list-style: none;
|
||||||
|
@ -42,63 +76,110 @@ ul {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.player > .queue {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-flow: column;
|
||||||
|
}
|
||||||
|
|
||||||
.queue-header {
|
.queue-header {
|
||||||
margin-top: 1.0rem;
|
margin-top: 1.0rem;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.queue-next {
|
.queue-next {
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
}
|
||||||
.queue-clear {
|
|
||||||
font-weight: bold;
|
|
||||||
display: flex;
|
|
||||||
line-height: 24px;
|
|
||||||
}
|
|
||||||
.queue-clear .material-symbols-outlined {
|
|
||||||
margin-right: 0.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.queue {
|
.queue {
|
||||||
margin-top: 0.5rem;
|
margin-top: 0.5rem;
|
||||||
|
overflow: auto;
|
||||||
|
scroll-behavior: smooth;
|
||||||
}
|
}
|
||||||
|
|
||||||
.queue li {
|
.queue ul li {
|
||||||
padding: 0 0.5rem;
|
padding: 0 0.5rem;
|
||||||
border-radius: .25rem;
|
border-radius: .25rem;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
cursor: grab;
|
||||||
}
|
}
|
||||||
.queue li.playing {
|
.queue ul li:hover {
|
||||||
background-color: #334;
|
background-color: #223;
|
||||||
}
|
}
|
||||||
|
|
||||||
.queue .metadata {
|
.queue ul li.playing {
|
||||||
|
background-color: #334;
|
||||||
|
}
|
||||||
|
@media (prefers-contrast: more) {
|
||||||
|
.queue ul li.playing {
|
||||||
|
background-color: black;
|
||||||
|
border: 2px solid white;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue ul .metadata {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
}
|
||||||
.queue .song__name {
|
|
||||||
|
.queue ul .metadata * {
|
||||||
|
-webkit-line-clamp: 1;
|
||||||
|
line-clamp: 1;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue ul .song__name {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
display: -webkit-box;
|
display: -webkit-box;
|
||||||
-webkit-line-clamp: 2; /* number of lines to show */
|
}
|
||||||
line-clamp: 2;
|
|
||||||
-webkit-box-orient: vertical;
|
.queue ul li:not(:hover) .remove {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue .remove button {
|
||||||
|
padding: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.browser .header {
|
||||||
|
display: flex;
|
||||||
|
flex-flow: column;
|
||||||
|
background-color: #334;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
padding: 0.75rem 0.5rem;
|
||||||
|
margin: 16px 16px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.browser .buttons {
|
||||||
|
display: flex;
|
||||||
|
flex-flow: row;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
}
|
||||||
|
.browser .buttons button {
|
||||||
|
margin-right: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
ul.breadcrumb {
|
ul.breadcrumb {
|
||||||
display: flex;
|
display: flex;
|
||||||
margin-bottom: 1rem;
|
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
list-style: none;
|
list-style: none;
|
||||||
background-color: #334;
|
margin-left: 0.5rem;
|
||||||
border-radius: .25rem;
|
}
|
||||||
padding: .75rem 1rem;
|
@media (prefers-contrast: more) {
|
||||||
|
ul.breadcrumb {
|
||||||
|
background-color: black;
|
||||||
|
border: 2px solid white;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
ul.breadcrumb li:not(:first-child)::before {
|
ul.breadcrumb li:not(:first-child)::before {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
|
|
||||||
padding-left: .5rem;
|
padding-left: .5rem;
|
||||||
padding-right: .1rem;
|
padding-right: .1rem;
|
||||||
|
|
||||||
color: #6c757d;
|
color: #6c757d;
|
||||||
content: "/";
|
content: "/";
|
||||||
}
|
}
|
||||||
|
@ -106,12 +187,14 @@ ul.breadcrumb li:not(:first-child)::before {
|
||||||
ul.dir {
|
ul.dir {
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
padding: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
ul.dir li {
|
ul.dir li {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
padding: .75rem .75rem;
|
padding: 0.5rem 0.5rem;
|
||||||
border-radius: 3px;
|
border-radius: 0.25rem;
|
||||||
|
min-height: 3rem;
|
||||||
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
@ -132,9 +215,22 @@ ul.dir li .material-symbols-outlined {
|
||||||
|
|
||||||
.albumart {
|
.albumart {
|
||||||
border-radius: 0.25rem;
|
border-radius: 0.25rem;
|
||||||
background-color: #445;
|
|
||||||
width: 48px;
|
width: 48px;
|
||||||
height: 48px;
|
height: 48px;
|
||||||
|
background: #445 url(/static/placeholder.webp);
|
||||||
|
background-size: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
.albumart a {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.albumart img {
|
||||||
|
visibility: hidden;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
}
|
}
|
||||||
|
|
||||||
.queue .albumart {
|
.queue .albumart {
|
||||||
|
@ -146,34 +242,60 @@ ul.dir li .material-symbols-outlined {
|
||||||
margin-right: 0.75rem;
|
margin-right: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.albumart img {
|
.track {
|
||||||
border-radius: 0.25rem;
|
margin-right: 0.75rem;
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
object-fit: cover;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.player {
|
.player {
|
||||||
width: 25rem;
|
width: 25rem;
|
||||||
|
padding: 1rem 1rem 0;
|
||||||
|
display: flex;
|
||||||
|
flex-flow: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
.player .nowplaying {
|
.player .nowplaying {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
position: relative;
|
||||||
flex-flow: column;
|
flex-flow: column;
|
||||||
background-color: #334;
|
background-color: #334;
|
||||||
border-radius: 0.25rem;
|
border-radius: 0.25rem;
|
||||||
height: 9.5rem;
|
height: 13.0rem;
|
||||||
|
}
|
||||||
|
@media (prefers-contrast: more) {
|
||||||
|
.player .nowplaying {
|
||||||
|
background-color: black;
|
||||||
|
border: 2px solid white;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.player .controls {
|
.player .progress {
|
||||||
|
background-color: #99f;
|
||||||
|
|
||||||
|
height: 0.5rem;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player .controls,
|
||||||
|
.player .settings {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-around;
|
justify-content: space-around;
|
||||||
padding: 0.5rem;
|
padding: 0 0.5rem 0.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.player .control {
|
.player .settings {
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player .controls button {
|
||||||
font-size: 40px;
|
font-size: 40px;
|
||||||
}
|
}
|
||||||
|
.player .settings button {
|
||||||
|
font-size: 25px;
|
||||||
|
}
|
||||||
|
|
||||||
.player .current {
|
.player .current {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
50
static/vendor/LICENSES
vendored
50
static/vendor/LICENSES
vendored
|
@ -1,50 +0,0 @@
|
||||||
# htmx.min.js; htmx-sse.js
|
|
||||||
|
|
||||||
https://github.com/bigskysoftware/htmx
|
|
||||||
|
|
||||||
BSD 2-Clause "Simplified" License
|
|
||||||
|
|
||||||
Copyright (c) 2020, Big Sky Software
|
|
||||||
All rights reserved.
|
|
||||||
|
|
||||||
Redistribution and use in source and binary forms, with or without
|
|
||||||
modification, are permitted provided that the following conditions are met:
|
|
||||||
|
|
||||||
1. Redistributions of source code must retain the above copyright notice, this
|
|
||||||
list of conditions and the following disclaimer.
|
|
||||||
|
|
||||||
2. Redistributions in binary form must reproduce the above copyright notice,
|
|
||||||
this list of conditions and the following disclaimer in the documentation
|
|
||||||
and/or other materials provided with the distribution.
|
|
||||||
|
|
||||||
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
|
||||||
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
|
||||||
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
|
||||||
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
|
||||||
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
|
||||||
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
|
||||||
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
|
||||||
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
|
||||||
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
|
||||||
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
|
||||||
|
|
||||||
|
|
||||||
# material-symbols-outlined
|
|
||||||
|
|
||||||
https://github.com/google/material-design-icons
|
|
||||||
|
|
||||||
Apache License Version 2.0
|
|
||||||
|
|
||||||
Copyright [yyyy] [name of copyright owner]
|
|
||||||
|
|
||||||
Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
you may not use this file except in compliance with the License.
|
|
||||||
You may obtain a copy of the License at
|
|
||||||
|
|
||||||
http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
|
|
||||||
Unless required by applicable law or agreed to in writing, software
|
|
||||||
distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
See the License for the specific language governing permissions and
|
|
||||||
limitations under the License.
|
|
77
static/vendor/LICENSES.md
vendored
Normal file
77
static/vendor/LICENSES.md
vendored
Normal file
|
@ -0,0 +1,77 @@
|
||||||
|
# htmx.min.js; htmx-sse.js
|
||||||
|
|
||||||
|
https://github.com/bigskysoftware/htmx
|
||||||
|
|
||||||
|
> BSD 2-Clause "Simplified" License
|
||||||
|
>
|
||||||
|
> Copyright (c) 2020, Big Sky Software
|
||||||
|
> All rights reserved.
|
||||||
|
>
|
||||||
|
> Redistribution and use in source and binary forms, with or without
|
||||||
|
> modification, are permitted provided that the following conditions are met:
|
||||||
|
>
|
||||||
|
> 1. Redistributions of source code must retain the above copyright notice, this
|
||||||
|
> list of conditions and the following disclaimer.
|
||||||
|
>
|
||||||
|
> 2. Redistributions in binary form must reproduce the above copyright notice,
|
||||||
|
> this list of conditions and the following disclaimer in the documentation
|
||||||
|
> and/or other materials provided with the distribution.
|
||||||
|
>
|
||||||
|
> THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
||||||
|
> AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
||||||
|
> IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
||||||
|
> DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
||||||
|
> FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
||||||
|
> DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
||||||
|
> SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
||||||
|
> CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
||||||
|
> OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
||||||
|
> OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||||
|
|
||||||
|
|
||||||
|
# material-symbols-outlined.woff2
|
||||||
|
|
||||||
|
https://github.com/google/material-design-icons
|
||||||
|
|
||||||
|
> Apache License Version 2.0
|
||||||
|
>
|
||||||
|
> Copyright [yyyy] [name of copyright owner]
|
||||||
|
>
|
||||||
|
> Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
> you may not use this file except in compliance with the License.
|
||||||
|
> You may obtain a copy of the License at
|
||||||
|
>
|
||||||
|
> http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
>
|
||||||
|
> Unless required by applicable law or agreed to in writing, software
|
||||||
|
> distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
> WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
> See the License for the specific language governing permissions and
|
||||||
|
> limitations under the License.
|
||||||
|
|
||||||
|
|
||||||
|
## Sortable.min.js
|
||||||
|
|
||||||
|
https://github.com/SortableJS/Sortable
|
||||||
|
|
||||||
|
> MIT License
|
||||||
|
>
|
||||||
|
> Copyright (c) 2019 All contributors to Sortable
|
||||||
|
>
|
||||||
|
> Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
> of this software and associated documentation files (the "Software"), to deal
|
||||||
|
> in the Software without restriction, including without limitation the rights
|
||||||
|
> to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
> copies of the Software, and to permit persons to whom the Software is
|
||||||
|
> furnished to do so, subject to the following conditions:
|
||||||
|
>
|
||||||
|
> The above copyright notice and this permission notice shall be included in all
|
||||||
|
> copies or substantial portions of the Software.
|
||||||
|
>
|
||||||
|
> THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
> IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
> FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
> AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
> LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
> OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
> SOFTWARE.
|
2
static/vendor/Sortable.min.js
vendored
Normal file
2
static/vendor/Sortable.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
185
static/vendor/htmx-sse.js
vendored
185
static/vendor/htmx-sse.js
vendored
|
@ -5,7 +5,7 @@ This extension adds support for Server Sent Events to htmx. See /www/extensions
|
||||||
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
(function(){
|
(function() {
|
||||||
|
|
||||||
/** @type {import("../htmx").HtmxInternalApi} */
|
/** @type {import("../htmx").HtmxInternalApi} */
|
||||||
var api;
|
var api;
|
||||||
|
@ -39,17 +39,19 @@ 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":
|
||||||
createEventSourceOnElement(evt.target);
|
ensureEventSourceOnElement(evt.target);
|
||||||
|
registerSSE(evt.target);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
@ -67,7 +69,7 @@ This extension adds support for Server Sent Events to htmx. See /www/extensions
|
||||||
* @returns EventSource
|
* @returns EventSource
|
||||||
*/
|
*/
|
||||||
function createEventSource(url) {
|
function createEventSource(url) {
|
||||||
return new EventSource(url, {withCredentials:true});
|
return new EventSource(url, { withCredentials: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
function splitOnWhitespace(trigger) {
|
function splitOnWhitespace(trigger) {
|
||||||
|
@ -90,7 +92,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) {
|
if (legacySSEValue != null) {
|
||||||
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(/:(.+)/);
|
||||||
|
@ -103,62 +105,23 @@ This extension adds support for Server Sent Events to htmx. See /www/extensions
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* createEventSourceOnElement creates a new EventSource connection on the provided element.
|
* registerSSE looks for attributes that can contain sse events, right
|
||||||
* If a usable EventSource already exists, then it is returned. If not, then a new EventSource
|
* now hx-trigger and sse-swap and adds listeners based on these attributes too
|
||||||
* is created and stored in the element's internalData.
|
* the closest event source
|
||||||
|
*
|
||||||
* @param {HTMLElement} elt
|
* @param {HTMLElement} elt
|
||||||
* @param {number} retryCount
|
|
||||||
* @returns {EventSource | null}
|
|
||||||
*/
|
*/
|
||||||
function createEventSourceOnElement(elt, retryCount) {
|
function registerSSE(elt) {
|
||||||
|
// Find closest existing event source
|
||||||
if (elt == null) {
|
var sourceElement = api.getClosestMatch(elt, hasEventSource);
|
||||||
return null;
|
if (sourceElement == null) {
|
||||||
|
// api.triggerErrorEvent(elt, "htmx:noSSESourceError")
|
||||||
|
return null; // no eventsource in parentage, orphaned element
|
||||||
}
|
}
|
||||||
|
|
||||||
var internalData = api.getInternalData(elt);
|
// Set internalData and source
|
||||||
|
var internalData = api.getInternalData(sourceElement);
|
||||||
// get URL from element's attribute
|
var source = internalData.sseEventSource;
|
||||||
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) {
|
||||||
|
@ -170,23 +133,27 @@ This extension adds support for Server Sent Events to htmx. See /www/extensions
|
||||||
var sseEventNames = getLegacySSESwaps(child);
|
var sseEventNames = getLegacySSESwaps(child);
|
||||||
}
|
}
|
||||||
|
|
||||||
for (var i = 0 ; i < sseEventNames.length ; i++) {
|
for (var i = 0; i < sseEventNames.length; i++) {
|
||||||
var sseEventName = sseEventNames[i].trim();
|
var sseEventName = sseEventNames[i].trim();
|
||||||
var listener = function(event) {
|
var listener = function(event) {
|
||||||
|
|
||||||
// If the parent is missing then close SSE and remove listener
|
// If the source is missing then close SSE
|
||||||
if (maybeCloseSSESource(elt)) {
|
if (maybeCloseSSESource(sourceElement)) {
|
||||||
source.removeEventListener(sseEventName, listener);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If the body no longer contains the element, remove the listener
|
||||||
|
if (!api.bodyContains(child)) {
|
||||||
|
source.removeEventListener(sseEventName, listener);
|
||||||
|
}
|
||||||
|
|
||||||
// swap the response into the DOM and trigger a notification
|
// swap the response into the DOM and trigger a notification
|
||||||
swap(child, event.data);
|
swap(child, event.data);
|
||||||
api.triggerEvent(elt, "htmx:sseMessage", event);
|
api.triggerEvent(elt, "htmx:sseMessage", event);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Register the new listener
|
// Register the new listener
|
||||||
api.getInternalData(elt).sseEventListener = listener;
|
api.getInternalData(child).sseEventListener = listener;
|
||||||
source.addEventListener(sseEventName, listener);
|
source.addEventListener(sseEventName, listener);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
@ -204,23 +171,85 @@ This extension adds support for Server Sent Events to htmx. See /www/extensions
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var listener = function(event) {
|
// remove the sse: prefix from here on out
|
||||||
|
sseEventName = sseEventName.substr(4);
|
||||||
|
|
||||||
// If parent is missing, then close SSE and remove listener
|
var listener = function() {
|
||||||
if (maybeCloseSSESource(elt)) {
|
if (maybeCloseSSESource(sourceElement)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!api.bodyContains(child)) {
|
||||||
source.removeEventListener(sseEventName, listener);
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Trigger events to be handled by the rest of htmx
|
ensureEventSource(child, sseURL, retryCount);
|
||||||
htmx.trigger(child, sseEventName, event);
|
});
|
||||||
htmx.trigger(child, "htmx:sseMessage", event);
|
|
||||||
|
// handle legacy sse, remove for HTMX2
|
||||||
|
queryAttributeOnThisOrChildren(elt, "hx-sse").forEach(function(child) {
|
||||||
|
var sseURL = getLegacySSEURL(child);
|
||||||
|
if (sseURL == null) {
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Register the new listener
|
ensureEventSource(child, sseURL, retryCount);
|
||||||
api.getInternalData(elt).sseEventListener = listener;
|
|
||||||
source.addEventListener(sseEventName.slice(4), listener);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
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)) {
|
||||||
|
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() {
|
||||||
|
ensureEventSourceOnElement(elt, Math.min(7, retryCount + 1));
|
||||||
|
}, timeout);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
source.onopen = function(evt) {
|
||||||
|
api.triggerEvent(elt, "htmx:sseOpen", { source: source });
|
||||||
|
}
|
||||||
|
|
||||||
|
api.getInternalData(elt).sseEventSource = source;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -253,12 +282,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) || api.hasAttribute(elt, "hx-sse")) {
|
if (api.hasAttribute(elt, attributeName)) {
|
||||||
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 + "], [hx-sse], [data-hx-sse]").forEach(function(node) {
|
elt.querySelectorAll("[" + attributeName + "], [data-" + attributeName + "]").forEach(function(node) {
|
||||||
result.push(node);
|
result.push(node);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -281,7 +310,7 @@ This extension adds support for Server Sent Events to htmx. See /www/extensions
|
||||||
|
|
||||||
api.selectAndSwap(swapSpec.swapStyle, target, elt, content, settleInfo);
|
api.selectAndSwap(swapSpec.swapStyle, target, elt, content, settleInfo);
|
||||||
|
|
||||||
settleInfo.elts.forEach(function (elt) {
|
settleInfo.elts.forEach(function(elt) {
|
||||||
if (elt.classList) {
|
if (elt.classList) {
|
||||||
elt.classList.add(htmx.config.settlingClass);
|
elt.classList.add(htmx.config.settlingClass);
|
||||||
}
|
}
|
||||||
|
@ -306,11 +335,11 @@ This extension adds support for Server Sent Events to htmx. See /www/extensions
|
||||||
function doSettle(settleInfo) {
|
function doSettle(settleInfo) {
|
||||||
|
|
||||||
return function() {
|
return function() {
|
||||||
settleInfo.tasks.forEach(function (task) {
|
settleInfo.tasks.forEach(function(task) {
|
||||||
task.call();
|
task.call();
|
||||||
});
|
});
|
||||||
|
|
||||||
settleInfo.elts.forEach(function (elt) {
|
settleInfo.elts.forEach(function(elt) {
|
||||||
if (elt.classList) {
|
if (elt.classList) {
|
||||||
elt.classList.remove(htmx.config.settlingClass);
|
elt.classList.remove(htmx.config.settlingClass);
|
||||||
}
|
}
|
||||||
|
@ -319,4 +348,8 @@ 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
|
@ -1,5 +1,6 @@
|
||||||
{# #}
|
{# #}
|
||||||
<ul class="breadcrumb">
|
<div class="header">
|
||||||
|
<ul class="breadcrumb">
|
||||||
<li>
|
<li>
|
||||||
<a
|
<a
|
||||||
href="/"
|
href="/"
|
||||||
|
@ -15,26 +16,59 @@
|
||||||
{{ component }}
|
{{ component }}
|
||||||
{% else %}
|
{% else %}
|
||||||
<a
|
<a
|
||||||
href="/?path={{ path[..i + 1].join("/") }}"
|
{% let encoded = path[..i + 1].join("/")|urlencode %}
|
||||||
hx-replace-url="/?path={{ path[..i + 1].join("/") }}"
|
href="/?path={{ encoded }}"
|
||||||
|
hx-replace-url="/?path={{ encoded }}"
|
||||||
hx-get="/browser"
|
hx-get="/browser"
|
||||||
hx-vals='{"path": "{{ path[..i + 1].join("/") }}"}'
|
hx-vals='{"path": "{{ encoded }}"}'
|
||||||
hx-target=".browser"
|
hx-target=".browser"
|
||||||
>{{ component }}</a>
|
>{{ component }}</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</li>
|
</li>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<ul class="dir" hx-boost="true">
|
<div class="buttons">
|
||||||
|
{% let encoded = path.join("/")|urlencode %}
|
||||||
|
<button hx-delete="/queue" hx-swap="none" hx-post="/queue?path={{ encoded }}">
|
||||||
|
<span class="material-symbols-outlined">playlist_add</span>
|
||||||
|
Queue all
|
||||||
|
</button>
|
||||||
|
<button hx-delete="/queue" hx-swap="none" hx-post="/queue?path={{ encoded }}&replace=true&play=true">
|
||||||
|
<span class="material-symbols-outlined">playlist_play</span>
|
||||||
|
Play all
|
||||||
|
</button>
|
||||||
|
<button hx-delete="/queue" hx-swap="none" hx-post="/queue?path={{ encoded }}&next=true">
|
||||||
|
<span class="material-symbols-outlined">playlist_add</span>
|
||||||
|
Play next
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul class="dir" hx-boost="true" tabindex="-1">
|
||||||
{% for entry in entries %}
|
{% for entry in entries %}
|
||||||
{% match entry %}
|
{% match entry %}
|
||||||
{% when mpd::Entry::Song with { name, path, artist } %}
|
{% when mpd::Entry::Song with { track, name, path, artist } %}
|
||||||
<li hx-post="/queue?path={{path}}" hx-swap="none" role="button">
|
<li
|
||||||
<span class="material-symbols-outlined">music_note</span>
|
hx-post="/queue?path={{ path|urlencode }}"
|
||||||
|
hx-trigger="click,keyup[key=='Enter']"
|
||||||
|
hx-swap="none"
|
||||||
|
role="button"
|
||||||
|
tabindex="0"
|
||||||
|
>
|
||||||
|
<span class="material-symbols-outlined" title="Song">music_note</span>
|
||||||
<div class="albumart">
|
<div class="albumart">
|
||||||
<img src="/art?path={{path}}" onerror="this.style.visibility = 'hidden'">
|
<img
|
||||||
|
src="/art?path={{ path|urlencode }}"
|
||||||
|
onload="this.style.visibility = 'visible'"
|
||||||
|
alt="Album art"
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
|
{% if let Some(track) = track %}
|
||||||
|
<div class="track">
|
||||||
|
{{ track }}.
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
<div class="song">
|
<div class="song">
|
||||||
<div class="song__name">{{ name }}</div>
|
<div class="song__name">{{ name }}</div>
|
||||||
<div class="song__artist">{{ artist }}</div>
|
<div class="song__artist">{{ artist }}</div>
|
||||||
|
@ -43,16 +77,21 @@
|
||||||
{% when mpd::Entry::Directory with { name, path } %}
|
{% when mpd::Entry::Directory with { name, path } %}
|
||||||
<li
|
<li
|
||||||
hx-get="/browser"
|
hx-get="/browser"
|
||||||
hx-vals='{"path": "{{ path }}"}'
|
hx-vals='{"path": "{{ path|urlencode }}"}'
|
||||||
hx-replace-url="/?path={{ path }}"
|
hx-replace-url="/?path={{ path|urlencode }}"
|
||||||
hx-target=".browser"
|
hx-target=".browser"
|
||||||
|
role="link"
|
||||||
>
|
>
|
||||||
<span class="material-symbols-outlined">folder</span>
|
<span class="material-symbols-outlined" title="Directory">folder</span>
|
||||||
<div class="song__name">{{ name }}</div>
|
<div class="song__name">
|
||||||
|
<a href="/?path={{ path|urlencode }}" hx-get="/browser" hx-sync="closest li:abort">
|
||||||
|
{{ name }}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
</li>
|
</li>
|
||||||
{% when mpd::Entry::Playlist with { name, path } %}
|
{% when mpd::Entry::Playlist with { name, path } %}
|
||||||
<li hx-post="/queue?path={{ path }}" hx-swap="none" role="button" >
|
<li hx-post="/queue?path={{ path|urlencode }}" hx-swap="none" role="button" >
|
||||||
<span class="material-symbols-outlined">playlist_play</span>
|
<span class="material-symbols-outlined" title="Playlist">playlist_play</span>
|
||||||
<div class="song">
|
<div class="song">
|
||||||
<div class="song__name">{{ name }}</div>
|
<div class="song__name">{{ name }}</div>
|
||||||
</a>
|
</a>
|
||||||
|
|
|
@ -1,18 +1,30 @@
|
||||||
{# Template #}
|
{# Template #}
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html>
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<title>Empede</title>
|
<title>Empede</title>
|
||||||
|
|
||||||
|
<!-- Empede version: {{ crate_version!() }} -->
|
||||||
|
|
||||||
<!-- Source: https://github.com/bigskysoftware/htmx -->
|
<!-- Source: https://github.com/bigskysoftware/htmx -->
|
||||||
<script src="/static/vendor/htmx.min.js"></script>
|
<script src="/static/vendor/htmx.min.js"></script>
|
||||||
<script src="/static/vendor/htmx-sse.js"></script>
|
<script src="/static/vendor/htmx-sse.js"></script>
|
||||||
|
|
||||||
|
<!-- Source: https://github.com/SortableJS/Sortable -->
|
||||||
|
<script src="/static/vendor/Sortable.min.js"></script>
|
||||||
|
|
||||||
<link rel="stylesheet" href="/static/style.css">
|
<link rel="stylesheet" href="/static/style.css">
|
||||||
<link href="/static/favicon.png" rel="icon" type="image/png">
|
<link href="/static/favicon.png" rel="icon" type="image/png">
|
||||||
|
|
||||||
|
<script>
|
||||||
|
let progressBar;
|
||||||
|
let elapsed;
|
||||||
|
let duration;
|
||||||
|
let progressInterval;
|
||||||
|
</script>
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body hx-ext="sse" sse-connect="/sse">
|
<body hx-ext="sse" sse-connect="/idle">
|
||||||
<div
|
<div
|
||||||
class="browser"
|
class="browser"
|
||||||
hx-trigger="load,sse:database"
|
hx-trigger="load,sse:database"
|
||||||
|
@ -21,8 +33,21 @@
|
||||||
></div>
|
></div>
|
||||||
|
|
||||||
<div class="player">
|
<div class="player">
|
||||||
<div hx-trigger="sse:player" hx-get="/player"></div>
|
<div class="nowplaying" hx-trigger="sse:player,sse:options" hx-get="/player"></div>
|
||||||
<div hx-trigger="sse:playlist,sse:player" hx-get="/queue"></div>
|
|
||||||
|
<div class="queue-header">
|
||||||
|
<div class="queue-next">Next in queue</div>
|
||||||
|
<button hx-delete="/queue" hx-swap="none">
|
||||||
|
<span class="material-symbols-outlined">playlist_remove</span>
|
||||||
|
Clear
|
||||||
|
</button>
|
||||||
|
<button hx-post="/shuffle" hx-swap="none">
|
||||||
|
<span class="material-symbols-outlined">shuffle</span>
|
||||||
|
Shuffle
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="queue" hx-trigger="sse:playlist,sse:player" hx-get="/queue"></div>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
@ -1,27 +1,24 @@
|
||||||
{# #}
|
{# #}
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<script>
|
|
||||||
{% if let Some(name) = name %}
|
<div class="current">
|
||||||
document.title = "{{ name }} - Empede";
|
|
||||||
{% else %}
|
|
||||||
document.title = "Empede";
|
|
||||||
{% endif %}
|
|
||||||
</script>
|
|
||||||
<div class="nowplaying">
|
|
||||||
<div class="current">
|
|
||||||
{% if let Some(song) = song %}
|
{% if let Some(song) = song %}
|
||||||
<div class="albumart">
|
<div class="albumart">
|
||||||
<a href="/art?path={{ song.file }}" target="_blank">
|
<a href="/art?path={{ song["file"]|urlencode }}" target="_blank">
|
||||||
<img src="/art?path={{ song.file }}" onerror="this.style.opacity = 0">
|
<img
|
||||||
|
src="/art?path={{ song["file"]|urlencode }}"
|
||||||
|
onload="this.style.visibility = 'visible'"
|
||||||
|
alt="Album art"
|
||||||
|
>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="metadata">
|
<div class="metadata">
|
||||||
{% if let Some(name) = name %}
|
{% if let Some(name) = name %}
|
||||||
<div class="song__name">{{ name }}</div>
|
<div class="song__name" title="Song name">{{ name }}</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if let Some(artist) = song.artist %}
|
{% if let Some(artist) = song.get("Artist") %}
|
||||||
<div>{{ artist }}</div>
|
<div class="song__artist" title="Artist">{{ artist }}</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
|
@ -29,24 +26,85 @@
|
||||||
Nothing playing right now
|
Nothing playing right now
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="controls">
|
|
||||||
<span
|
|
||||||
hx-post="/previous" hx-swap="none"
|
|
||||||
class="control material-symbols-outlined" role="button"
|
|
||||||
>skip_previous</span>
|
|
||||||
<span
|
|
||||||
hx-post="/play" hx-swap="none"
|
|
||||||
class="control material-symbols-outlined" role="button"
|
|
||||||
>play_arrow</span>
|
|
||||||
<span
|
|
||||||
hx-post="/pause" hx-swap="none"
|
|
||||||
class="control material-symbols-outlined" role="button"
|
|
||||||
>pause</span>
|
|
||||||
<span
|
|
||||||
hx-post="/next" hx-swap="none"
|
|
||||||
class="control material-symbols-outlined" role="button"
|
|
||||||
>skip_next</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="controls" hx-swap="none" hx-trigger="click,keyUp[key=='Enter']">
|
||||||
|
<button
|
||||||
|
hx-post="/previous"
|
||||||
|
class="control material-symbols-outlined" role="button" title="Previous track"
|
||||||
|
>skip_previous</button>
|
||||||
|
|
||||||
|
{% if state == "play" %}
|
||||||
|
<button
|
||||||
|
hx-post="/pause"
|
||||||
|
class="control material-symbols-outlined" role="button" title="Pause"
|
||||||
|
>pause</button>
|
||||||
|
{% else %}
|
||||||
|
<button
|
||||||
|
hx-post="/play"
|
||||||
|
class="control material-symbols-outlined" role="button" title="Play"
|
||||||
|
>play_arrow</button>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<button
|
||||||
|
hx-post="/next"
|
||||||
|
class="control material-symbols-outlined" role="button" title="Next track"
|
||||||
|
>skip_next</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="settings" hx-swap="none" hx-trigger="click,keyUp[key=='Enter']">
|
||||||
|
<button
|
||||||
|
hx-post="/consume"
|
||||||
|
class="control material-symbols-outlined {% if consume %}active{% endif %}"
|
||||||
|
role="button" title="Consume"
|
||||||
|
style="font-size: 32px"
|
||||||
|
>delete_sweep</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
hx-post="/random"
|
||||||
|
class="control material-symbols-outlined {% if random %}active{% endif %}"
|
||||||
|
role="button" title="Shuffle"
|
||||||
|
>shuffle</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
hx-post="/repeat"
|
||||||
|
class="control material-symbols-outlined {% if repeat %}active{% endif %}"
|
||||||
|
role="button" title="Repeat"
|
||||||
|
>repeat</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
hx-post="/single"
|
||||||
|
class="control material-symbols-outlined {% if single %}active{% endif %}"
|
||||||
|
role="button" title="Single"
|
||||||
|
>filter_1</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="progress" style="width: {{ elapsed / duration * 100.0 }}%"></div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
{% if let Some(name) = name %}
|
||||||
|
{% if state == "play" %}
|
||||||
|
document.title = "▶ " + {{ name|json|safe }} + " - Empede";
|
||||||
|
{% else %}
|
||||||
|
document.title = "⏸ " + {{ name|json|safe }} + " - Empede";
|
||||||
|
{% endif %}
|
||||||
|
{% else %}
|
||||||
|
document.title = "Empede";
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if state == "play" %}
|
||||||
|
progressBar = document.querySelector(".nowplaying .progress");
|
||||||
|
elapsed = {{ elapsed }};
|
||||||
|
duration = {{ duration }};
|
||||||
|
|
||||||
|
if (progressInterval) {
|
||||||
|
window.clearInterval(progressInterval);
|
||||||
|
}
|
||||||
|
|
||||||
|
progressInterval = window.setInterval(() => {
|
||||||
|
elapsed += 1.0;
|
||||||
|
let progress = Math.min(elapsed / duration, 1.0);
|
||||||
|
progressBar.style.width = `${progress * 100}%`;
|
||||||
|
}, 1000);
|
||||||
|
{% endif %}
|
||||||
|
</script>
|
||||||
|
|
|
@ -1,26 +1,57 @@
|
||||||
{# Template #}
|
{# Template #}
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
|
|
||||||
<div class="queue-header">
|
<ul>
|
||||||
<div class="queue-next">Next in queue</div>
|
|
||||||
<div class="queue-clear" role="button" hx-delete="/queue">
|
|
||||||
<span class="material-symbols-outlined">playlist_remove</span>
|
|
||||||
Clear
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<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 %}
|
||||||
|
hx-post="/play?position={{ item.position|urlencode }}"
|
||||||
|
hx-trigger="click,keyup[key='Enter']"
|
||||||
|
hx-swap="none"
|
||||||
|
>
|
||||||
<div class="albumart">
|
<div class="albumart">
|
||||||
<img src="/art?path={{ item.file }}" onerror="this.style.opacity = 0">
|
<img
|
||||||
|
src="/art?path={{ item.file|urlencode }}"
|
||||||
|
onload="this.style.visibility = 'visible'"
|
||||||
|
alt="Album art"
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
<div class="metadata">
|
<div class="metadata">
|
||||||
<div class="song__name">{{ item.title }}</div>
|
<div class="song__name" title="Song name">{{ item.title }}</div>
|
||||||
{% if let Some(artist) = item.artist %}
|
{% if let Some(artist) = item.artist %}
|
||||||
<div class="song__artist">{{ artist }}</div>
|
<div class="song__artist" title="Artist">{{ artist }}</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
<div class="remove">
|
||||||
|
<button class="material-symbols-outlined" title="Remove" hx-delete="/queue?id={{ item.id }}">close</button>
|
||||||
|
</div>
|
||||||
</li>
|
</li>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
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
|
||||||
|
.matchMedia("(prefers-reduced-motion: reduce)")
|
||||||
|
.matches;
|
||||||
|
|
||||||
|
new Sortable(document.querySelector(".queue ul"), {
|
||||||
|
animation: isReduced ? 0 : 100,
|
||||||
|
onEnd: (event) => fetch("/queue/move", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {"content-type": "application/json"},
|
||||||
|
body: JSON.stringify({from: event.oldIndex, to: event.newIndex}),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
scrollCurrentSongIntoView();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
Loading…
Reference in a new issue