[gui-rework] Make the code not spagetti, kinda #1
|
|
@ -1,8 +1,2 @@
|
|||
[target.aarch64-unknown-linux-gnu]
|
||||
linker="aarch64-linux-gnu-gcc"
|
||||
|
||||
[env]
|
||||
XMPD_MANIFEST_PATH="./manifest.json"
|
||||
XMPD_SETTINGS_PATH="./settings.toml"
|
||||
XMPD_CACHE_PATH="./cache"
|
||||
|
||||
|
|
|
|||
8
.gitignore
vendored
|
|
@ -1,4 +1,4 @@
|
|||
/target/
|
||||
/cache/
|
||||
settings.toml
|
||||
valgrind.log
|
||||
/out
|
||||
/target
|
||||
/config.json
|
||||
/manifest.json
|
||||
|
|
|
|||
2322
Cargo.lock
generated
60
Cargo.toml
|
|
@ -1,54 +1,30 @@
|
|||
[workspace]
|
||||
resolver="2"
|
||||
members=[
|
||||
"xmpd-core",
|
||||
"xmpd-manifest",
|
||||
"xmpd-gui",
|
||||
"xmpd-cliargs",
|
||||
"xmpd-cache",
|
||||
"xmpd-settings",
|
||||
"xmpd-tooling",
|
||||
"xmpd-player", "xmpd-update",
|
||||
# "xmpd-tui"
|
||||
]
|
||||
[package]
|
||||
name = "mcmg"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[workspace.package]
|
||||
version="2.1.1"
|
||||
edition="2024"
|
||||
repository="https://git.mcorangehq.xyz/XOR64/xmpd/"
|
||||
license="GPL-3.0"
|
||||
authors=[
|
||||
"MCorange <mcorange@mcorangehq.xyz>",
|
||||
"xomf <xomf@the-atf-shot-my.dog>"
|
||||
]
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
|
||||
|
||||
[workspace.dependencies]
|
||||
[dependencies]
|
||||
anstyle = "1.0.6"
|
||||
anyhow = "1.0.81"
|
||||
camino = { version="1.1.6", features = ["serde1"] }
|
||||
camino = "1.1.6"
|
||||
clap = { version = "4.5.4", features = ["derive"] }
|
||||
eframe = "0.27.2"
|
||||
egui = { version = "0.27.2", features = ["color-hex", "serde"] }
|
||||
egui_extras = { version = "0.27.2", features = ["all_loaders"] }
|
||||
egui = "0.27.2"
|
||||
egui_extras = "0.27.2"
|
||||
env_logger = "0.11.3"
|
||||
futures = "0.3.30"
|
||||
lazy_static = "1.4.0"
|
||||
libc = "0.2.153"
|
||||
log = "0.4.21"
|
||||
# notify-rust = "4.11.3"
|
||||
# open = "5.3.0"
|
||||
reqwest = { version = "0.12.3", features = ["blocking", "h2", "http2", "rustls-tls", "json"], default-features = false }
|
||||
notify-rust = "4.11.3"
|
||||
open = "5.3.0"
|
||||
reqwest = { version = "0.12.3", features = ["h2", "http2", "rustls-tls"], default-features = false }
|
||||
serde = { version = "1.0.197", features = ["derive"] }
|
||||
serde_json = "1.0.115"
|
||||
url = { version = "2.5.0", features = ["serde"] }
|
||||
uuid = { version = "1.11.0", features = ["serde", "v4"] }
|
||||
# serde_traitobject = "0.2.8"
|
||||
tokio = { version = "1.37.0", features = ["macros", "rt-multi-thread", "process", "sync"] }
|
||||
url = "2.5.0"
|
||||
windows = { version = "0.56.0", features = ["Win32_Foundation", "Win32_Storage_FileSystem", "Win32_System_Console"] }
|
||||
# zip-extensions = "0.6.20"
|
||||
dirs="5.0.1"
|
||||
winresource = "0.1.17"
|
||||
toml = "0.8.19"
|
||||
rfd = "0.15.1"
|
||||
rodio = { version = "0.20.1", features = ["symphonia-all"] }
|
||||
image = "0.25.5"
|
||||
downcast-rs = "2.0.2"
|
||||
semver = "1.0.27"
|
||||
parse-changelog = "0.6.14"
|
||||
zip-extensions = "0.6.2"
|
||||
|
|
|
|||
5
DEV.md
|
|
@ -1,5 +0,0 @@
|
|||
[ ] listen along feature using ws and or p2p, downloading music when connectedd if you dont have it, matched by either the url, or a global id set by server
|
||||
[ ] Internationalisation
|
||||
[ ] Music Player
|
||||
[ ] Playlist exporting to folder, zip, tar balls, etc
|
||||
|
||||
50
README.md
|
|
@ -1,50 +0,0 @@
|
|||
# (XMPD) Xor64 Music Player/Downloader
|
||||
|
||||
|
||||
# **NOTE** This readme has outdated info, and needs to be updated
|
||||
|
||||
|
||||
An open source music downloader AND player (soon (TM))
|
||||
|
||||
## Design
|
||||
Written in rust, it *tries* to be fast, reliable, and actually useful, as it doesnt stream the music from a server where it could be
|
||||
deleted and lost forever.
|
||||
It uses [egui](https://www.egui.rs/) for a crossplatform frontend that makes it possible to run almost anywhere.
|
||||
The music is downloaded via [ytdlp](#Dependencies) and [spotdl](#Dependencies), and converted to your favorite format via [ffmpeg](#Dependencies)
|
||||
|
||||
## Offline usage
|
||||
Your whole music library is downloaded to your music folder (unless its being ran in standalone mode). Saved in your selected format.
|
||||
All of the info required to download your songs is stored in 1 file (!). So all you need to backup all of your music is just 1 relatively small file AND you get the added benefit of easily moving your music between devices with just 1 manifest file, 1 executable (and 3 [dependencies](#dependencies)). Just press `download all` and see as all of your playlists appear in your hard drive, powered by Open Source software.
|
||||
|
||||
## Dependencies
|
||||
[ffmpeg](https://ffmpeg.org/): To convert your music files to your desired format.
|
||||
[spotdl](https://github.com/spotDL/spotify-downloader): To download music from spotify, because ofcourse spotify had to be the special child
|
||||
[ytdlp](https://github.com/yt-dlp/yt-dlp): To download music from every single other source
|
||||
|
||||
The rust dependencies can be found in the [Cargo.toml](/Cargo.toml) file.
|
||||
|
||||
## Installation
|
||||
Just run `xmpd-setup-{version}.sh` for GNU/Linux or `xmpd-setup-{version}.ps1` for Windows!
|
||||
|
||||
## Compilation
|
||||
Building the amazing xmpd executable is really simple:
|
||||
- make sure rust is installed by running `cargo --help`
|
||||
- clone the git repo by running `git clone https://git.mcorangehq.xyz/XOR64/xmpd.git` in your desired directory
|
||||
- run `cargo build --release`
|
||||
- The executable should be in `./target/release/xmpd`
|
||||
|
||||
NOTE: If you run the executable from the terminal it will start downloading all of your songs, to open the gui run `xmpd gui`,
|
||||
for more info run `xmpd --help`
|
||||
|
||||
## Contributing
|
||||
Fork and clone your repo, you will probably want to use ssh for cloning.
|
||||
After adding your features or bugfixes push the changes to your fork.
|
||||
After making sure xmpd works fine, and you havent found any bugs, make a pull request on `https://git.mcorangehq.xyz/XOR64/xmpd/pulls`
|
||||
If we approve your changes it will be pushed to the main branch, and added to the contributors!
|
||||
|
||||
Current todos are stored in [DEV.md](/DEV.md)
|
||||
|
||||
## Licensing
|
||||
See [LICENSE.md](/LICENSE.md) for the licensing terms
|
||||
|
||||
|
||||
31
\
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
use std::collections::HashMap;
|
||||
|
||||
use super::Gui;
|
||||
|
||||
mod song_edit;
|
||||
|
||||
trait Window {
|
||||
fn show(&mut self, gui: &mut Gui, ctx: &egui::Context, open: &mut bool) -> anyhow::Result<()>;
|
||||
fn name(&self) -> &'static str;
|
||||
}
|
||||
|
||||
pub enum WindowIndex {
|
||||
SongEdit
|
||||
}
|
||||
|
||||
|
||||
pub struct WindowManager {
|
||||
windows: HashMap<WindowIndex, Box<dyn Window>>
|
||||
}
|
||||
|
||||
impl WindowManager {
|
||||
pub fn new() -> Self {
|
||||
let mut windows = HashMap::new();
|
||||
windows.instert(WindowIndex::SongEdit, Box::<song_edit::GuiSongEditor>::default());
|
||||
Self {
|
||||
windows: HashMap::from_iter([
|
||||
(WindowIndex::SongEdit, Box::<song_edit::GuiSongEditor>::default())
|
||||
])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 16 KiB |
|
|
@ -1 +0,0 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#FFFFFF"><path shape-rendering="crispEdges" d="M120-240v-80h720v80H120Zm0-200v-80h720v80H120Zm0-200v-80h720v80H120Z"/></svg>
|
||||
|
Before Width: | Height: | Size: 223 B |
|
|
@ -1 +0,0 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#FFFFFF"><path d="M382-240 154-468l57-57 171 171 367-367 57 57-424 424Z"/></svg>
|
||||
|
Before Width: | Height: | Size: 179 B |
|
|
@ -1 +0,0 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#e8eaed"><path d="M256-213.85 213.85-256l224-224-224-224L256-746.15l224 224 224-224L746.15-704l-224 224 224 224L704-213.85l-224-224-224 224Z"/></svg>
|
||||
|
Before Width: | Height: | Size: 247 B |
|
|
@ -1 +0,0 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#FFFFFF"><path d="M480-320 280-520l56-58 104 104v-326h80v326l104-104 56 58-200 200ZM240-160q-33 0-56.5-23.5T160-240v-120h80v120h480v-120h80v120q0 33-23.5 56.5T720-160H240Z"/></svg>
|
||||
|
Before Width: | Height: | Size: 279 B |
|
|
@ -1 +0,0 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#FFFFFF"><path d="M480-280q17 0 28.5-11.5T520-320q0-17-11.5-28.5T480-360q-17 0-28.5 11.5T440-320q0 17 11.5 28.5T480-280Zm-40-160h80v-240h-80v240Zm40 360q-83 0-156-31.5T197-197q-54-54-85.5-127T80-480q0-83 31.5-156T197-763q54-54 127-85.5T480-880q83 0 156 31.5T763-763q54 54 85.5 127T880-480q0 83-31.5 156T763-197q-54 54-127 85.5T480-80Zm0-80q134 0 227-93t93-227q0-134-93-227t-227-93q-134 0-227 93t-93 227q0 134 93 227t227 93Zm0-320Z"/></svg>
|
||||
|
Before Width: | Height: | Size: 538 B |
BIN
assets/icon.ico
|
Before Width: | Height: | Size: 15 KiB |
|
|
@ -1 +0,0 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" height="16px" viewBox="0 -960 960 960" width="16px" fill="#FFFFFF"><path d="M440-280h80v-240h-80v240Zm40-320q17 0 28.5-11.5T520-640q0-17-11.5-28.5T480-680q-17 0-28.5 11.5T440-640q0 17 11.5 28.5T480-600Zm0 520q-83 0-156-31.5T197-197q-54-54-85.5-127T80-480q0-83 31.5-156T197-763q54-54 127-85.5T480-880q83 0 156 31.5T763-763q54 54 85.5 127T880-480q0 83-31.5 156T763-197q-54 54-127 85.5T480-80Zm0-80q134 0 227-93t93-227q0-134-93-227t-227-93q-134 0-227 93t-93 227q0 134 93 227t227 93Zm0-320Z"/></svg>
|
||||
|
Before Width: | Height: | Size: 536 B |
|
|
@ -1,4 +0,0 @@
|
|||
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||
<svg width="800px" height="800px" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M4 15H2V1H4L10 7V1H14V15H10V9L4 15Z" fill="#FFFFFF"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 295 B |
|
|
@ -1,19 +0,0 @@
|
|||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||
<svg width="800px" height="800px" viewBox="0 0 20 20" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||
|
||||
<title>music [#1005]</title>
|
||||
<desc>Created with Sketch.</desc>
|
||||
<defs>
|
||||
|
||||
</defs>
|
||||
<g id="Page-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
|
||||
<g id="Dribbble-Light-Preview" transform="translate(-260.000000, -3759.000000)" fill="#ffffff">
|
||||
<g id="icons" transform="translate(56.000000, 160.000000)">
|
||||
<path d="M224,3601.05129 L224,3610.55901 C224,3612.90979 222.17612,3614.95492 219.888035,3614.89646 C217.266519,3614.82877 215.248971,3612.1662 216.234285,3609.31593 C216.777356,3607.74464 218.297755,3606.71797 219.920978,3606.69233 C220.695653,3606.68105 220.976173,3606.88208 222.003416,3607.24105 L222.003416,3604.12822 C222.003416,3603.56207 221.556181,3603.10258 221.005124,3603.10258 L213.018786,3603.10258 C212.467729,3603.10258 212.020494,3603.56207 212.020494,3604.12822 L212.020494,3614.65851 C212.020494,3617.02057 210.179644,3619.07289 207.881575,3618.99801 C205.681339,3618.92622 203.914362,3617.02775 204.00321,3614.73031 C204.090061,3612.51594 205.989811,3610.84209 208.147121,3610.79081 C209.166377,3610.76619 209.352059,3610.92619 210.02391,3611.34363 L210.02391,3601.05129 C210.02391,3599.91795 210.91838,3599 212.020494,3599 L222.003416,3599 C223.106529,3599 224,3599.91795 224,3601.05129" id="music-[#1005]">
|
||||
|
||||
</path>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.6 KiB |
|
|
@ -1,56 +0,0 @@
|
|||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||
|
||||
<svg
|
||||
width="32"
|
||||
height="32"
|
||||
viewBox="0 0 32 32"
|
||||
version="1.1"
|
||||
id="svg1"
|
||||
sodipodi:docname="pause2.svg"
|
||||
inkscape:version="1.4 (e7c3feb100, 2024-10-09)"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg">
|
||||
<sodipodi:namedview
|
||||
id="namedview1"
|
||||
pagecolor="#ffffff"
|
||||
bordercolor="#ffffff"
|
||||
borderopacity="0.25"
|
||||
inkscape:showpageshadow="2"
|
||||
inkscape:pageopacity="0.0"
|
||||
inkscape:pagecheckerboard="0"
|
||||
inkscape:deskcolor="#d1d1d1"
|
||||
inkscape:document-units="px"
|
||||
inkscape:zoom="18.296388"
|
||||
inkscape:cx="20.222571"
|
||||
inkscape:cy="14.046488"
|
||||
inkscape:window-width="1898"
|
||||
inkscape:window-height="1037"
|
||||
inkscape:window-x="10"
|
||||
inkscape:window-y="10"
|
||||
inkscape:window-maximized="1"
|
||||
inkscape:current-layer="layer1" />
|
||||
<defs
|
||||
id="defs1" />
|
||||
<g
|
||||
inkscape:label="Layer 1"
|
||||
inkscape:groupmode="layer"
|
||||
id="layer1">
|
||||
<rect
|
||||
style="fill:#ffffff;stroke-width:1.35128"
|
||||
id="rect1"
|
||||
width="9.1821404"
|
||||
height="31.973524"
|
||||
x="0.054655612"
|
||||
y="0.1093111" />
|
||||
<rect
|
||||
style="fill:#ffffff;stroke-width:1.35128"
|
||||
id="rect1-5"
|
||||
width="9.1821404"
|
||||
height="31.973524"
|
||||
x="22.809692"
|
||||
y="0.091137752" />
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.5 KiB |
|
|
@ -1,59 +0,0 @@
|
|||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||
|
||||
<svg
|
||||
width="32"
|
||||
height="32"
|
||||
viewBox="0 0 32 32"
|
||||
version="1.1"
|
||||
id="svg1"
|
||||
sodipodi:docname="play2.svg"
|
||||
inkscape:version="1.4 (e7c3feb100, 2024-10-09)"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg">
|
||||
<sodipodi:namedview
|
||||
id="namedview1"
|
||||
pagecolor="#ffffff"
|
||||
bordercolor="#ffffff"
|
||||
borderopacity="0.25"
|
||||
inkscape:showpageshadow="2"
|
||||
inkscape:pageopacity="0.0"
|
||||
inkscape:pagecheckerboard="0"
|
||||
inkscape:deskcolor="#FFFFFF"
|
||||
inkscape:document-units="px"
|
||||
inkscape:zoom="18.296388"
|
||||
inkscape:cx="20.222571"
|
||||
inkscape:cy="14.046488"
|
||||
inkscape:window-width="1898"
|
||||
inkscape:window-height="1037"
|
||||
inkscape:window-x="10"
|
||||
inkscape:window-y="10"
|
||||
inkscape:window-maximized="1"
|
||||
inkscape:current-layer="layer1" />
|
||||
<defs
|
||||
id="defs1" />
|
||||
<g
|
||||
inkscape:label="Layer 1"
|
||||
inkscape:groupmode="layer"
|
||||
id="layer1">
|
||||
<path
|
||||
sodipodi:type="star"
|
||||
style="fill:#ffffff"
|
||||
id="path1"
|
||||
inkscape:flatsided="false"
|
||||
sodipodi:sides="3"
|
||||
sodipodi:cx="1.004831"
|
||||
sodipodi:cy="6.8019323"
|
||||
sodipodi:r1="22.598255"
|
||||
sodipodi:r2="11.299128"
|
||||
sodipodi:arg1="1.0471976"
|
||||
sodipodi:arg2="2.0943951"
|
||||
inkscape:rounded="0"
|
||||
inkscape:randomized="0"
|
||||
d="M 12.303958,26.372596 -4.6447328,16.587264 -21.593424,6.8019312 -4.6447329,-2.9833992 12.303959,-12.76873 l 0,19.5706623 z"
|
||||
inkscape:transform-center-x="-5.3338952"
|
||||
transform="matrix(-0.94412506,0,0,0.81752476,11.623548,10.454857)" />
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.8 KiB |
|
|
@ -1,39 +0,0 @@
|
|||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg
|
||||
height="24px"
|
||||
viewBox="0 -960 960 960"
|
||||
width="24px"
|
||||
fill="#FFFFFF"
|
||||
version="1.1"
|
||||
id="svg1"
|
||||
sodipodi:docname="plus.svg"
|
||||
inkscape:version="1.4 (e7c3feb100, 2024-10-09)"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg">
|
||||
<defs
|
||||
id="defs1" />
|
||||
<sodipodi:namedview
|
||||
id="namedview1"
|
||||
pagecolor="#000000"
|
||||
bordercolor="#000000"
|
||||
borderopacity="0.25"
|
||||
inkscape:showpageshadow="2"
|
||||
inkscape:pageopacity="0.0"
|
||||
inkscape:pagecheckerboard="0"
|
||||
inkscape:deskcolor="#d1d1d1"
|
||||
inkscape:zoom="45.254834"
|
||||
inkscape:cx="7.4025241"
|
||||
inkscape:cy="12.396466"
|
||||
inkscape:window-width="1898"
|
||||
inkscape:window-height="1037"
|
||||
inkscape:window-x="10"
|
||||
inkscape:window-y="10"
|
||||
inkscape:window-maximized="1"
|
||||
inkscape:current-layer="svg1" />
|
||||
<path
|
||||
shape-rendering="crispEdges"
|
||||
d="M440-440H200v-80h240v-240h80v240h240v80H520v240h-80v-240Z"
|
||||
id="path1" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.1 KiB |
|
|
@ -1,4 +0,0 @@
|
|||
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||
<svg width="800px" height="800px" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M2 1H6V7L12 1H14V15H12L6 9V15H2V1Z" fill="#FFFFFF"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 294 B |
|
|
@ -1,4 +0,0 @@
|
|||
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||
<svg width="800px" height="800px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M11 6C13.7614 6 16 8.23858 16 11M16.6588 16.6549L21 21M19 11C19 15.4183 15.4183 19 11 19C6.58172 19 3 15.4183 3 11C3 6.58172 6.58172 3 11 3C15.4183 3 19 6.58172 19 11Z" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 493 B |
|
|
@ -1 +0,0 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#e8eaed"><path d="M320-320h320v-320H320v320ZM480-80q-83 0-156-31.5T197-197q-54-54-85.5-127T80-480q0-83 31.5-156T197-763q54-54 127-85.5T480-880q83 0 156 31.5T763-763q54 54 85.5 127T880-480q0 83-31.5 156T763-197q-54 54-127 85.5T480-80Zm0-80q134 0 227-93t93-227q0-134-93-227t-227-93q-134 0-227 93t-93 227q0 134 93 227t227 93Zm0-320Z"/></svg>
|
||||
|
Before Width: | Height: | Size: 436 B |
|
|
@ -1 +0,0 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#FFFFFF"><path d="m40-120 440-760 440 760H40Zm138-80h604L480-720 178-200Zm302-40q17 0 28.5-11.5T520-280q0-17-11.5-28.5T480-320q-17 0-28.5 11.5T440-280q0 17 11.5 28.5T480-240Zm-40-120h80v-200h-80v200Zm40-100Z"/></svg>
|
||||
|
Before Width: | Height: | Size: 315 B |
4
manifest.default.json
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
{
|
||||
"format": "m4a",
|
||||
"genres": {}
|
||||
}
|
||||
2586
manifest.json
1373
manifest.toml
|
|
@ -1,3 +0,0 @@
|
|||
[toolchain]
|
||||
channel="nightly"
|
||||
|
||||
18
scripts/build-release.sh
Executable file
|
|
@ -0,0 +1,18 @@
|
|||
#!/usr/bin/bash
|
||||
|
||||
if [[ -z "$1" ]]; then
|
||||
echo "Please suppy a version: 0.0.0[a | b | rc-0]"
|
||||
exit
|
||||
fi
|
||||
|
||||
set -e
|
||||
|
||||
cargo build --release --target x86_64-pc-windows-gnu
|
||||
cargo build --release --target x86_64-unknown-linux-gnu
|
||||
cargo build --release --target aarch64-unknown-linux-gnu
|
||||
|
||||
strip --strip-unneeded ./target/x86_64-pc-windows-gnu/release/mcmg.exe -o ./target/mcmg_win32.exe
|
||||
strip --strip-unneeded ./target/x86_64-unknown-linux-gnu/release/mcmg -o ./target/mcmg_linux_x86_64
|
||||
aarch64-linux-gnu-strip --strip-unneeded ./target/aarch64-unknown-linux-gnu/release/mcmg -o ./target/mcmg_linux_aarch64
|
||||
cp ./scripts/setup-template.sh "./target/mcmg-setup-$1.sh"
|
||||
cp ./scripts/setup-template.ps1 "./target/mcmg-setup-$1.ps1"
|
||||
|
|
@ -1,12 +0,0 @@
|
|||
#!/usr/bin/bash
|
||||
|
||||
set -x
|
||||
rm target/xmpd target/xmpd.exe
|
||||
|
||||
set -e
|
||||
|
||||
cargo build --release --target x86_64-pc-windows-gnu
|
||||
cargo build --release --target x86_64-unknown-linux-gnu
|
||||
|
||||
cp target/x86_64-unknown-linux-gnu/release/xmpd target/xmpd
|
||||
cp target/x86_64-pc-windows-gnu/release/xmpd.exe target/xmpd.exe
|
||||
|
|
@ -1,52 +0,0 @@
|
|||
"""
|
||||
Converts legacy manifest to v1 json
|
||||
"""
|
||||
|
||||
import uuid
|
||||
import json
|
||||
import sys
|
||||
|
||||
def main(inp: str, out: str):
|
||||
manifest = {
|
||||
"songs": {},
|
||||
"playlists": {}
|
||||
}
|
||||
with open(inp, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
_format = data["format"] # unused
|
||||
for pname in data["playlists"]:
|
||||
pid = str(uuid.uuid4())
|
||||
manifest["playlists"][pid] = {
|
||||
"name": pname,
|
||||
"author": "Unknown",
|
||||
"songs": []
|
||||
}
|
||||
for sname in data["playlists"][pname]["songs"]:
|
||||
asn = sname.split(" - ", 2)
|
||||
author = None
|
||||
name = None
|
||||
if len(asn) < 2:
|
||||
author = "Unknown"
|
||||
name = sname
|
||||
else:
|
||||
author = asn[0]
|
||||
name = asn[1]
|
||||
song = data["playlists"][pname]["songs"][sname]
|
||||
|
||||
sid = str(uuid.uuid4())
|
||||
manifest["playlists"][pid]["songs"].append(sid)
|
||||
manifest["songs"][sid] = {
|
||||
"name": name,
|
||||
"author": author,
|
||||
"url": song["url"],
|
||||
"source_type": song["typ"]
|
||||
}
|
||||
converted = json.dumps(manifest)
|
||||
with open(out, "w", encoding="utf-8") as f:
|
||||
f.write(converted)
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) < 3:
|
||||
print(f"Usage: {sys.argv[0]} [in] [out]")
|
||||
sys.exit(1)
|
||||
main(sys.argv[1], sys.argv[2])
|
||||
23
scripts/setup-template.ps1
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
|
||||
$MyInvocation.MyCommand.Name -match '([0-9]+\.[0-9]+\.[0-9]+([ab]|(rc[-]*[0-9]*)))'
|
||||
$Ver = $Matches[1]
|
||||
|
||||
|
||||
if (-not (Get-Command ffmpeg -ErrorAction SilentlyContinue)) {
|
||||
winget install Gyan.FFmpeg
|
||||
}
|
||||
|
||||
if (-not (Get-Command "yt-dlp" -ErrorAction SilentlyContinue)) {
|
||||
winget install "yt-dlp.yt-dlp"
|
||||
}
|
||||
|
||||
if (-not (Get-Command spotdl -ErrorAction SilentlyContinue)) {
|
||||
if (-not (Get-Command python -ErrorAction SilentlyContinue)) {
|
||||
winget install "Python.Python.3.12"
|
||||
}
|
||||
python -m pip install spotdl
|
||||
}
|
||||
|
||||
$url = "https://git.mcorangehq.xyz/XOR64/music/releases/download/$Ver/mcmg_win32.exe"
|
||||
|
||||
Invoke-WebRequest -Uri $url -OutFile "mcmg.exe"
|
||||
42
scripts/setup-template.sh
Executable file
|
|
@ -0,0 +1,42 @@
|
|||
#!/usr/bin/bash
|
||||
|
||||
set -e
|
||||
|
||||
PROG_VER=$(echo $0 | grep -o -E "[0-9]+\.[0-9]+\.[0-9]+([ab]|(rc[-]*[0-9]*))")
|
||||
|
||||
echo $PROG_VER
|
||||
|
||||
function cmd_exists() {
|
||||
if ! command -v $1 &> /dev/null
|
||||
then
|
||||
return 1
|
||||
else
|
||||
return 0
|
||||
fi
|
||||
}
|
||||
|
||||
if cmd_exists "pacman"; then
|
||||
if cmd_exists "yay"; then
|
||||
yay -Sy --needed ffmpeg yt-dlp spotdl curl
|
||||
else
|
||||
sudo pacman -Sy --needed ffmpeg yt-dlp python python-pip python-pipx curl
|
||||
pipx install spotdl
|
||||
fi
|
||||
fi
|
||||
|
||||
if cmd_exists "apt"; then
|
||||
sudo apt-get update
|
||||
sudo apt-get install python3 python3-pip ffmpeg curl
|
||||
|
||||
# updates all python packages, uncomment if you get errors for packages
|
||||
# pip3 freeze | grep -v '^\-e' | cut -d = -f 1 | xargs -n1 pip3 install -U
|
||||
python3 -m pip install --upgrade pip
|
||||
python3 -m pip install spotdl
|
||||
python3 -m pip install yt-dlp
|
||||
fi
|
||||
|
||||
curl "https://git.mcorangehq.xyz/XOR64/music/releases/download/${PROG_VER}/mcmg_linux_x86_64" -o mcmg
|
||||
|
||||
|
||||
|
||||
|
||||
46
src/config/cli.rs
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
use camino::Utf8PathBuf;
|
||||
use clap::{Parser, Subcommand};
|
||||
|
||||
|
||||
#[derive(Debug, Parser, Default, Clone)]
|
||||
pub struct CliArgs {
|
||||
/// Show more info
|
||||
#[arg(long, short)]
|
||||
pub debug: bool,
|
||||
|
||||
/// Path to manifest
|
||||
#[arg(long, short, default_value_t=Utf8PathBuf::from("./manifest.json"))]
|
||||
pub manifest: Utf8PathBuf,
|
||||
|
||||
/// Output directory
|
||||
#[arg(long, short, default_value_t=Utf8PathBuf::from("./out"))]
|
||||
pub output: Utf8PathBuf,
|
||||
|
||||
/// Config path
|
||||
#[arg(long, short, default_value_t=Utf8PathBuf::from("./config.json"))]
|
||||
pub config: Utf8PathBuf,
|
||||
|
||||
#[command(subcommand)]
|
||||
pub command: Option<CliCommand>,
|
||||
|
||||
}
|
||||
|
||||
#[derive(Debug, Subcommand, Clone)]
|
||||
pub enum CliCommand {
|
||||
Download,
|
||||
Add {
|
||||
#[arg(long, short)]
|
||||
url: String,
|
||||
#[arg(long, short)]
|
||||
name: String,
|
||||
#[arg(long, short)]
|
||||
playlist: String
|
||||
},
|
||||
AddPlaylist {
|
||||
#[arg(long, short)]
|
||||
url: String,
|
||||
#[arg(long, short)]
|
||||
name: String
|
||||
},
|
||||
Gui
|
||||
}
|
||||
133
src/config/mod.rs
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
pub mod cli;
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use clap::Parser;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use anyhow::Result;
|
||||
use crate::util::{self, isatty};
|
||||
|
||||
use self::cli::CliArgs;
|
||||
|
||||
// const YTDLP_DL_URL: &'static str = "https://github.com/yt-dlp/yt-dlp/archive/refs/heads/master.zip";
|
||||
// const SPOTDL_DL_URL: &'static str = "https://github.com/spotDL/spotify-downloader/archive/refs/heads/master.zip";
|
||||
|
||||
#[derive(Debug, Default, Clone)]
|
||||
pub struct ConfigWrapper {
|
||||
pub cfg: Config,
|
||||
pub cli: cli::CliArgs,
|
||||
pub isatty: bool
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Default, Clone)]
|
||||
pub struct Config {
|
||||
pub ytdlp: ConfigYtdlp,
|
||||
pub spotdl: ConfigSpotdl,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Default, Clone)]
|
||||
pub struct ConfigYtdlp {
|
||||
pub path: PathBuf,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Default, Clone)]
|
||||
pub struct ConfigSpotdl {
|
||||
pub path: PathBuf,
|
||||
}
|
||||
|
||||
|
||||
impl ConfigWrapper {
|
||||
pub async fn parse() -> Result<Self> {
|
||||
let mut s = Self::default();
|
||||
s.cli = cli::CliArgs::parse();
|
||||
crate::logger::init_logger(s.cli.debug);
|
||||
s.cfg = Config::parse(&s.cli).await?;
|
||||
s.isatty = isatty();
|
||||
Ok(s)
|
||||
}
|
||||
}
|
||||
|
||||
impl Config {
|
||||
pub async fn parse(cli: &CliArgs) -> Result<Self> {
|
||||
if !cli.config.exists() {
|
||||
log::info!("Config doesnt exist");
|
||||
return Self::setup_config(&cli).await;
|
||||
}
|
||||
|
||||
let data = std::fs::read_to_string(&cli.config)?;
|
||||
let data: Self = serde_json::from_str(&data)?;
|
||||
Ok(data)
|
||||
}
|
||||
|
||||
async fn setup_config(cli: &CliArgs) -> Result<Self> {
|
||||
let mut s = Self::default();
|
||||
let mut error = false;
|
||||
|
||||
match util::is_program_in_path("yt-dlp") {
|
||||
Some(p) => {
|
||||
s.ytdlp.path = p;
|
||||
},
|
||||
|
||||
None => {
|
||||
error = true;
|
||||
log::error!("could not find yt-dlp, please install it.");
|
||||
log::info!(" - With winget (Windows only) (recommended):");
|
||||
log::info!(" - Most new windows versions have winget installed, if not, instructions here: https://learn.microsoft.com/en-us/windows/package-manager/winget/#install-winget");
|
||||
log::info!(" - run `winget install yt-dlp`");
|
||||
log::info!(" - With chocolatey (Windows only):");
|
||||
log::info!(" - Make sure you have chocolatey installed - https://chocolatey.org/install");
|
||||
log::info!(" - run `choco install yt-dlp` as Admin");
|
||||
log::info!(" - With pip (from python) (Cross platform)");
|
||||
log::info!(" - Make sure you have python installed");
|
||||
log::info!(" - pip install yt-dlp");
|
||||
log::info!(" - Using your distro's package manager (Unix/BSD only) (Not recommended)")
|
||||
}
|
||||
}
|
||||
|
||||
match util::is_program_in_path("spotdl") {
|
||||
Some(p) => {
|
||||
s.spotdl.path = p;
|
||||
},
|
||||
|
||||
None => {
|
||||
let res = crate::prompt::prompt_bool("Spotdl is not installed but if you dont need to download music from spotify you dont need it, skip it?", None);
|
||||
if res {
|
||||
s.spotdl.path = PathBuf::from("UNUSED");
|
||||
} else {
|
||||
error = true;
|
||||
log::error!("could not find spotdl, please install it. ");
|
||||
log::info!(" - With pip (from python) (Cross platform) (recommended)");
|
||||
log::info!(" - Make sure you have python installed - https://www.python.org/downloads/");
|
||||
log::info!(" - pip install spotdl");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
match util::is_program_in_path("ffmpeg") {
|
||||
Some(_) => (),
|
||||
|
||||
None => {
|
||||
error = true;
|
||||
log::error!("could not find ffmpeg, please install it.");
|
||||
log::info!(" - With winget (Windows only) (recommended):");
|
||||
log::info!(" - Most new windows versions have winget installed, if not, instructions here: https://learn.microsoft.com/en-us/windows/package-manager/winget/#install-winget");
|
||||
log::info!(" - run `winget install --id=Gyan.FFmpeg -e`");
|
||||
log::info!(" - With chocolatey (Windows only):");
|
||||
log::info!(" - Make sure you have chocolatey installed - https://chocolatey.org/install");
|
||||
log::info!(" - run `choco install ffmpeg` as Admin");
|
||||
}
|
||||
}
|
||||
|
||||
if !error {
|
||||
s.save(cli.config.clone().into_std_path_buf())?;
|
||||
}
|
||||
|
||||
Ok(s)
|
||||
}
|
||||
|
||||
fn save(&self, path: PathBuf) -> anyhow::Result<()> {
|
||||
let data = serde_json::to_string_pretty(self)?;
|
||||
std::fs::write(path, data)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
15
src/constants.rs
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
|
||||
#[cfg(target_family="windows")]
|
||||
mod constants {
|
||||
pub const PATH_VAR_SEP: &'static str = ";";
|
||||
pub const EXEC_EXT: &'static str = "exe";
|
||||
}
|
||||
|
||||
#[cfg(target_family="unix")]
|
||||
mod constants {
|
||||
pub const PATH_VAR_SEP: &'static str = ":";
|
||||
pub const EXEC_EXT: &'static str = "";
|
||||
}
|
||||
|
||||
|
||||
pub use constants::*;
|
||||
184
src/downloader.rs
Normal file
|
|
@ -0,0 +1,184 @@
|
|||
use std::{collections::HashMap, path::PathBuf, process::Stdio};
|
||||
|
||||
use lazy_static::lazy_static;
|
||||
use log::Level;
|
||||
use tokio::sync::{Mutex, RwLock};
|
||||
|
||||
use crate::{config::ConfigWrapper, manifest::{song::{Song, SongType}, Format, Manifest}};
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Clone)]
|
||||
struct Proc {
|
||||
url: String,
|
||||
path: String,
|
||||
finished: bool
|
||||
}
|
||||
|
||||
lazy_static!(
|
||||
static ref PROCESSES: Mutex<RwLock<HashMap<usize, Proc>>> = Mutex::new(RwLock::new(HashMap::new()));
|
||||
);
|
||||
|
||||
#[derive(Debug, Default, Clone)]
|
||||
pub struct Downloader {
|
||||
count: usize,
|
||||
nb_initial_song_count: usize,
|
||||
nb_cache: Vec<(String, String, Song, Format)>
|
||||
}
|
||||
|
||||
impl Downloader {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_initial_song_count_nb(&self) -> usize {
|
||||
self.nb_initial_song_count
|
||||
}
|
||||
|
||||
pub fn get_songs_left_nb(&self) -> usize {
|
||||
self.nb_cache.len() + crate::process_manager::proc_count()
|
||||
}
|
||||
|
||||
pub fn download_song_nb(&mut self, cfg: &ConfigWrapper, pname: &String, sname: &String, song: &Song, format: &Format) -> anyhow::Result<()> {
|
||||
self.nb_cache.push((pname.clone(), sname.clone(), song.clone(), format.clone()));
|
||||
self.nb_initial_song_count += 1;
|
||||
self.download_all_nb_poll(cfg)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn download_all_nb(&mut self, manifest: &Manifest, cfg: &ConfigWrapper) -> anyhow::Result<Option<usize>> {
|
||||
for (pname, playlist) in manifest.get_playlists() {
|
||||
for (sname, song) in playlist.get_songs() {
|
||||
self.nb_cache.push((pname.clone(), sname.clone(), song.clone(), manifest.get_format().clone()));
|
||||
}
|
||||
}
|
||||
self.nb_initial_song_count = self.nb_cache.len();
|
||||
|
||||
self.download_all_nb_poll(cfg)
|
||||
}
|
||||
|
||||
pub fn download_all_nb_poll(&mut self, cfg: &ConfigWrapper) -> anyhow::Result<Option<usize>> {
|
||||
if !crate::process_manager::is_proc_queue_full(10) {
|
||||
if let Some((pname, sname, song, format)) = self.nb_cache.pop() {
|
||||
self.download_song(cfg, &sname, &song, &pname, &format)?;
|
||||
}
|
||||
}
|
||||
if self.get_songs_left_nb() == 0 {
|
||||
self.nb_initial_song_count = 0;
|
||||
}
|
||||
if crate::process_manager::proc_count() == 0 && self.nb_cache.is_empty() {
|
||||
Ok(None)
|
||||
} else {
|
||||
Ok(Some(crate::process_manager::purge_done_procs()))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
pub async fn download_all(&mut self, manifest: &Manifest, cfg: &ConfigWrapper) -> anyhow::Result<usize> {
|
||||
let format = manifest.get_format();
|
||||
|
||||
for (name, playlist) in manifest.get_playlists() {
|
||||
for (song_name, song) in playlist.get_songs() {
|
||||
self.download_song(cfg, song_name, song, &name, format)?;
|
||||
self.count += crate::process_manager::wait_for_procs_untill(10)?;
|
||||
}
|
||||
}
|
||||
self.count += crate::process_manager::wait_for_procs_untill(0)?;
|
||||
Ok(self.count)
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn download_playlist(&mut self, cfg: &ConfigWrapper, url: &String, pname: &String, format: &Format) -> anyhow::Result<usize> {
|
||||
self.download_playlist_nb(cfg, url, pname, format)?;
|
||||
let mut count = 0;
|
||||
while let Some(c) = self.download_all_nb_poll(cfg)? {
|
||||
count += c;
|
||||
}
|
||||
Ok(count)
|
||||
}
|
||||
|
||||
pub fn download_playlist_nb(&mut self, cfg: &ConfigWrapper, url: &String, pname: &String, format: &Format) -> anyhow::Result<HashMap<String, Song>> {
|
||||
log::warn!("This automatically assumes its a youtube link as it is currently the only supported playlist source");
|
||||
let mut cmd = tokio::process::Command::new(&cfg.cfg.ytdlp.path);
|
||||
cmd.args([
|
||||
"--flat-playlist",
|
||||
"--simulate",
|
||||
"-O", "%(url)s|%(title)s",
|
||||
url.as_str()
|
||||
]);
|
||||
cmd
|
||||
.stderr(Stdio::null())
|
||||
.stdout(Stdio::piped());
|
||||
|
||||
let ftr = cmd.output();
|
||||
|
||||
let mut ret = HashMap::new();
|
||||
|
||||
let out = futures::executor::block_on(ftr)?.stdout;
|
||||
let out = String::from_utf8(out)?;
|
||||
for line in out.lines() {
|
||||
let mut split_text = line.split("|").collect::<Vec<&str>>();
|
||||
let url = split_text.swap_remove(0).to_string();
|
||||
let sname = split_text.join("|");
|
||||
let song = Song::from_url_str(url)?.set_type(SongType::Youtube).clone();
|
||||
self.nb_cache.push((pname.clone(), sname.clone(), song.clone(), format.clone()));
|
||||
ret.insert(sname, song.clone());
|
||||
}
|
||||
self.nb_initial_song_count += out.lines().count();
|
||||
self.download_all_nb_poll(cfg)?;
|
||||
Ok(ret)
|
||||
}
|
||||
|
||||
pub fn download_song(&mut self, cfg: &ConfigWrapper, name: &String, song: &Song, playlist: &String, format: &Format) -> anyhow::Result<()> {
|
||||
let dl_dir = format!("{}/{playlist}", cfg.cli.output);
|
||||
let dl_file = format!("{dl_dir}/{}.{}", name, &format);
|
||||
log::debug!("Checking: {dl_file}");
|
||||
if PathBuf::from(&dl_file).exists() {
|
||||
log::debug!("File {dl_file} exists, skipping");
|
||||
return Ok(())
|
||||
}
|
||||
|
||||
log::debug!("File {dl_file} doesnt exist, downloading");
|
||||
let mut cmd = match song.get_type() {
|
||||
|
||||
&SongType::Youtube => {
|
||||
log::debug!("Song {} is from yotube", song.get_url_str());
|
||||
let mut cmd = tokio::process::Command::new(&cfg.cfg.ytdlp.path);
|
||||
cmd.args([
|
||||
"-x",
|
||||
"--audio-format",
|
||||
&format.to_string(),
|
||||
"-o",
|
||||
dl_file.as_str(),
|
||||
song.get_url_str().as_str()
|
||||
]);
|
||||
cmd
|
||||
}
|
||||
SongType::Spotify => {
|
||||
|
||||
let mut cmd = tokio::process::Command::new(&cfg.cfg.spotdl.path);
|
||||
cmd.args([
|
||||
"--format",
|
||||
&format.to_string(),
|
||||
"--output",
|
||||
dl_dir.as_str(),
|
||||
song.get_url_str().as_str()
|
||||
]);
|
||||
cmd
|
||||
}
|
||||
url => {
|
||||
log::error!("Unknown or unsupported hostname '{:?}'", url);
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
|
||||
if log::max_level() < Level::Debug {
|
||||
cmd.stdout(Stdio::null()).stderr(Stdio::null());
|
||||
};
|
||||
|
||||
crate::process_manager::add_proc(cmd, format!("Downloaded {dl_file}"))?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
14
src/logger.rs
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
use log::LevelFilter;
|
||||
|
||||
|
||||
pub fn init_logger(debug: bool) {
|
||||
let level = if debug {
|
||||
LevelFilter::Debug
|
||||
} else {
|
||||
LevelFilter::Info
|
||||
};
|
||||
env_logger::builder()
|
||||
.format_timestamp(None)
|
||||
.filter_level(level)
|
||||
.init();
|
||||
}
|
||||
33
src/main.rs
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
#![feature(downcast_unchecked)]
|
||||
|
||||
use config::ConfigWrapper;
|
||||
|
||||
|
||||
// TODO: Possibly use https://docs.rs/ytextract/latest/ytextract/ instead of ytdlp
|
||||
mod manifest;
|
||||
mod logger;
|
||||
mod downloader;
|
||||
mod util;
|
||||
mod prompt;
|
||||
mod config;
|
||||
mod constants;
|
||||
mod process_manager;
|
||||
mod ui;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
let Ok(cfg) = ConfigWrapper::parse().await else {
|
||||
return;
|
||||
};
|
||||
|
||||
let mut manifest = match manifest::Manifest::load_new(&cfg.cli.manifest.clone().into_std_path_buf()) {
|
||||
Ok(m) => m,
|
||||
Err(e) => {
|
||||
log::error!("Failed to parse manifest file {}: {e}", cfg.cli.manifest);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
let _ = ui::cli::command_run(&cfg, &mut manifest).await;
|
||||
}
|
||||
137
src/manifest/mod.rs
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
// pub mod v1;
|
||||
|
||||
pub mod song;
|
||||
pub mod playlist;
|
||||
use song::Song;
|
||||
|
||||
use std::{collections::HashMap, fmt::{Debug, Display}, path::PathBuf};
|
||||
|
||||
use anyhow::{bail, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
|
||||
const DEFAULT_MANIFEST: &'static str = include_str!("../../manifest.default.json");
|
||||
|
||||
|
||||
|
||||
#[allow(non_camel_case_types)]
|
||||
#[derive(Debug, Serialize, Deserialize, Clone, Default)]
|
||||
pub enum Format {
|
||||
#[default]
|
||||
m4a,
|
||||
aac,
|
||||
flac,
|
||||
mp3,
|
||||
vaw,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone, Default)]
|
||||
pub struct Manifest {
|
||||
#[serde(skip)]
|
||||
path: PathBuf,
|
||||
format: Format,
|
||||
playlists: HashMap<String, playlist::Playlist>,
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
#[allow(dead_code)]
|
||||
impl Manifest {
|
||||
pub fn get_format(&self) -> &Format {
|
||||
&self.format
|
||||
}
|
||||
pub fn add_song(&mut self, playlist_name: &String, name: String, song: Song) -> Option<Song> {
|
||||
self.get_playlist_mut(playlist_name)?.add_song(name, song)
|
||||
}
|
||||
pub fn get_song(&self, playlist_name: &String, name: &String) -> Option<&Song> {
|
||||
self.get_playlist(playlist_name)?.get_song(name)
|
||||
}
|
||||
pub fn get_song_mut(&mut self, playlist_name: &String, name: &String) -> Option<&mut Song> {
|
||||
self.get_playlist_mut(playlist_name)?.get_song_mut(name)
|
||||
}
|
||||
pub fn add_playlist(&mut self, playlist_name: String) {
|
||||
self.playlists.insert(playlist_name, Default::default());
|
||||
}
|
||||
pub fn get_playlist(&self, playlist_name: &String) -> Option<&playlist::Playlist> {
|
||||
self.playlists.get(playlist_name)
|
||||
}
|
||||
pub fn get_playlist_mut(&mut self, playlist_name: &String) -> Option<&mut playlist::Playlist> {
|
||||
self.playlists.get_mut(playlist_name)
|
||||
}
|
||||
pub fn get_playlists(&self) -> &HashMap<String, playlist::Playlist> {
|
||||
&self.playlists
|
||||
}
|
||||
pub fn get_playlists_mut(&mut self) -> &mut HashMap<String, playlist::Playlist> {
|
||||
&mut self.playlists
|
||||
}
|
||||
pub fn get_song_count(&self) -> usize {
|
||||
let mut count = 0;
|
||||
for (_, v) in &self.playlists {
|
||||
count += v.len();
|
||||
}
|
||||
count
|
||||
}
|
||||
pub fn load(&mut self, p: Option<&PathBuf>) -> Result<()> {
|
||||
let path = p.unwrap_or(&self.path);
|
||||
log::debug!("Path: {path:?}");
|
||||
let data = std::fs::read_to_string(path)?;
|
||||
|
||||
let s: Self = serde_json::from_str(data.as_str())?;
|
||||
self.playlists = s.playlists;
|
||||
self.format = s.format;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
pub fn save(&self, p: Option<&PathBuf>) -> Result<()> {
|
||||
let path = p.unwrap_or(&self.path);
|
||||
log::debug!("Path: {path:?}");
|
||||
let data = serde_json::to_string_pretty(self)?;
|
||||
std::fs::write(path, data)?;
|
||||
Ok(())
|
||||
}
|
||||
pub fn load_new(p: &PathBuf) -> Result<Self> {
|
||||
|
||||
if !p.exists() {
|
||||
std::fs::write(p, DEFAULT_MANIFEST)?;
|
||||
}
|
||||
|
||||
let mut s = Self::default();
|
||||
log::debug!("Path: {p:?}");
|
||||
s.path = p.clone();
|
||||
s.load(Some(p))?;
|
||||
Ok(s)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
impl TryFrom<String> for Format {
|
||||
type Error = anyhow::Error;
|
||||
fn try_from(value: String) -> std::prelude::v1::Result<Self, Self::Error> {
|
||||
match value.as_str() {
|
||||
"m4a" => Ok(Self::m4a),
|
||||
"aac" => Ok(Self::aac),
|
||||
"flac" => Ok(Self::flac),
|
||||
"mp3" => Ok(Self::mp3),
|
||||
"vaw" => Ok(Self::vaw),
|
||||
v => bail!("Unknown format {v}")
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
impl Display for Format {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Format::m4a => write!(f, "m4a")?,
|
||||
Format::aac => write!(f, "aac")?,
|
||||
Format::flac => write!(f, "flac")?,
|
||||
Format::mp3 => write!(f, "mp3")?,
|
||||
Format::vaw => write!(f, "vaw")?,
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
54
src/manifest/playlist.rs
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
use egui::ahash::HashMap;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::song::Song;
|
||||
|
||||
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone, Default)]
|
||||
pub struct Playlist {
|
||||
songs: HashMap<String, Song>
|
||||
}
|
||||
|
||||
|
||||
|
||||
impl Playlist {
|
||||
|
||||
pub fn add_song(&mut self, name: String, song: Song) -> Option<Song> {
|
||||
self.songs.insert(name, song)
|
||||
}
|
||||
|
||||
pub fn remove_song(&mut self, name: &String) -> Option<Song> {
|
||||
self.songs.remove(name)
|
||||
}
|
||||
|
||||
pub fn get_song(&self, name: &String) -> Option<&Song> {
|
||||
self.songs.get(name)
|
||||
}
|
||||
|
||||
pub fn get_songs(&self) -> &HashMap<String, Song> {
|
||||
&self.songs
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn get_songs_mut(&mut self) -> &mut HashMap<String, Song> {
|
||||
&mut self.songs
|
||||
}
|
||||
|
||||
pub fn get_song_mut(&mut self, name: &String) -> Option<&mut Song> {
|
||||
self.songs.get_mut(name)
|
||||
}
|
||||
|
||||
pub fn len(&self) -> usize {
|
||||
self.songs.len()
|
||||
}
|
||||
}
|
||||
|
||||
impl IntoIterator for Playlist {
|
||||
type Item = (String, Song);
|
||||
type IntoIter = std::collections::hash_map::IntoIter<String, Song>;
|
||||
fn into_iter(self) -> Self::IntoIter {
|
||||
self.songs.into_iter()
|
||||
}
|
||||
}
|
||||
|
||||
83
src/manifest/song.rs
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
use std::str::FromStr;
|
||||
|
||||
use anyhow::{bail, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, Default)]
|
||||
pub enum SongType {
|
||||
#[default]
|
||||
Youtube,
|
||||
Spotify,
|
||||
Soundcloud,
|
||||
}
|
||||
|
||||
impl ToString for SongType {
|
||||
fn to_string(&self) -> String {
|
||||
let s = match self {
|
||||
SongType::Youtube => "Youtube",
|
||||
SongType::Spotify => "Spotify",
|
||||
SongType::Soundcloud => "Soundcloud",
|
||||
};
|
||||
String::from(s)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct Song {
|
||||
url: String,
|
||||
typ: SongType
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
impl Song {
|
||||
pub fn from_url_str(url: String) -> Result<Self> {
|
||||
Self::from_url(url::Url::from_str(url.as_str())?)
|
||||
}
|
||||
pub fn from_url(url: url::Url) -> Result<Self> {
|
||||
Ok(Self {
|
||||
url: url.to_string(),
|
||||
typ: url.try_into()?,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn set_type(&mut self, typ: SongType) -> &mut Self {
|
||||
self.typ = typ;
|
||||
self
|
||||
}
|
||||
pub fn get_url(&self) -> Result<url::Url> {
|
||||
Ok(url::Url::from_str(&self.url)?)
|
||||
}
|
||||
pub fn get_url_str(&self) -> &String {
|
||||
&self.url
|
||||
}
|
||||
pub fn get_url_str_mut(&mut self) -> &mut String {
|
||||
&mut self.url
|
||||
}
|
||||
pub fn get_type(&self) -> &SongType {
|
||||
&self.typ
|
||||
}
|
||||
pub fn get_type_mut(&mut self) -> &mut SongType {
|
||||
&mut self.typ
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
impl TryFrom<url::Url> for SongType {
|
||||
type Error = anyhow::Error;
|
||||
|
||||
fn try_from(url: url::Url) -> std::prelude::v1::Result<Self, Self::Error> {
|
||||
let Some(host) = url.host_str() else {
|
||||
bail!("{url} does not have a host");
|
||||
};
|
||||
|
||||
match host {
|
||||
"youtube.com" | "youtu.be" | "www.youtube.com" => Ok(Self::Youtube),
|
||||
"open.spotify.com" => Ok(Self::Spotify),
|
||||
"SOUNDCLOUD" => Ok(Self::Soundcloud), // TODO: Fix this
|
||||
_ => bail!("Unknwon host {url}")
|
||||
}
|
||||
}
|
||||
}
|
||||
79
src/process_manager.rs
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
use std::{collections::HashMap, sync::{atomic::{AtomicUsize, Ordering}, Mutex, RwLock}};
|
||||
|
||||
use tokio::process::Command;
|
||||
|
||||
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Clone)]
|
||||
struct Proc {
|
||||
msg: String,
|
||||
finished: bool
|
||||
}
|
||||
|
||||
lazy_static::lazy_static!(
|
||||
static ref PROCESSES: Mutex<RwLock<HashMap<usize, Proc>>> = Mutex::new(RwLock::new(HashMap::new()));
|
||||
);
|
||||
|
||||
static PROC_INC: AtomicUsize = AtomicUsize::new(0);
|
||||
|
||||
|
||||
|
||||
pub fn add_proc(mut cmd: Command, msg: String) -> anyhow::Result<()> {
|
||||
let mut proc = cmd.spawn()?;
|
||||
let id = PROC_INC.fetch_add(1, Ordering::AcqRel);
|
||||
|
||||
tokio::spawn(async move {
|
||||
let id = id;
|
||||
proc.wait().await
|
||||
.expect("child process encountered an error");
|
||||
PROCESSES.lock().unwrap().write().unwrap().get_mut(&id).unwrap().finished = true;
|
||||
});
|
||||
|
||||
PROCESSES.lock().unwrap().write().unwrap().insert(id, Proc {
|
||||
finished: false,
|
||||
msg,
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn proc_count() -> usize {
|
||||
PROCESSES.lock().unwrap().read().unwrap().len()
|
||||
}
|
||||
|
||||
pub fn is_proc_queue_full(max: usize) -> bool {
|
||||
let proc_cnt = PROCESSES.lock().unwrap().read().unwrap().len();
|
||||
proc_cnt >= max
|
||||
}
|
||||
|
||||
pub fn purge_done_procs() -> usize {
|
||||
let mut finish_count = 0;
|
||||
let procs = {
|
||||
PROCESSES.lock().unwrap().read().unwrap().clone()
|
||||
};
|
||||
|
||||
for (idx, proc) in procs {
|
||||
if proc.finished {
|
||||
{
|
||||
PROCESSES.lock().unwrap().write().unwrap().remove(&idx);
|
||||
}
|
||||
log::info!("{}", proc.msg);
|
||||
finish_count += 1;
|
||||
}
|
||||
}
|
||||
finish_count
|
||||
}
|
||||
|
||||
/// Waits for processes to finish untill the proc count is lower or equal to `max`
|
||||
pub fn wait_for_procs_untill(max: usize) -> anyhow::Result<usize> {
|
||||
// NOTE: This looks really fucked because i dont want to deadlock the processes so i lock PROCESSES for as little as possible
|
||||
// NOTE: So its also kinda really slow
|
||||
let mut finish_count = 0;
|
||||
loop {
|
||||
if !is_proc_queue_full(max) {
|
||||
return Ok(finish_count);
|
||||
}
|
||||
finish_count += purge_done_procs();
|
||||
}
|
||||
}
|
||||
148
src/prompt.rs
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
use std::{collections::HashMap, io::Write};
|
||||
|
||||
|
||||
//pub(crate) fn simple_prompt(p: &str) -> String {
|
||||
//
|
||||
// print!("{c}prompt{r}: {p} > ",
|
||||
// c=anstyle::AnsiColor::Cyan.render_fg(),
|
||||
// r=anstyle::Reset.render()
|
||||
// );
|
||||
//
|
||||
// // I dont care if it fails
|
||||
// let _ = std::io::stdout().flush();
|
||||
//
|
||||
// let mut buf = String::new();
|
||||
// let _ = std::io::stdin().read_line(&mut buf);
|
||||
//
|
||||
// buf.trim().to_string()
|
||||
//}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub(crate) fn prompt_with_list(p: &str, options: &[&str]) -> usize {
|
||||
println!("{c}prompt{r}: {p}",
|
||||
c=anstyle::AnsiColor::Cyan.render_fg(),
|
||||
r=anstyle::Reset.render()
|
||||
);
|
||||
|
||||
for (i, op) in options.iter().enumerate() {
|
||||
println!(" - {}: {}", i, op);
|
||||
}
|
||||
|
||||
print!("> ");
|
||||
// I dont care if it fails
|
||||
let _ = std::io::stdout().flush();
|
||||
|
||||
let mut buf = String::new();
|
||||
let _ = std::io::stdin().read_line(&mut buf);
|
||||
|
||||
if let Ok(num) = buf.parse::<usize>() {
|
||||
if num <= options.len() {
|
||||
return num;
|
||||
} else {
|
||||
return prompt_with_list(p, options);
|
||||
}
|
||||
} else {
|
||||
return prompt_with_list(p, options);
|
||||
}
|
||||
}
|
||||
|
||||
// pub(crate) fn prompt_with_list_or_str(p: &str, options: &[String]) -> String {
|
||||
// println!("{c}prompt{r}: {p} (select with number or input text)",
|
||||
// c=anstyle::AnsiColor::Cyan.render_fg(),
|
||||
// r=anstyle::Reset.render()
|
||||
// );
|
||||
//
|
||||
// for (i, op) in options.iter().enumerate() {
|
||||
// println!(" - {}: {}", i, op);
|
||||
// }
|
||||
//
|
||||
// print!("> ");
|
||||
// // I dont care if it fails
|
||||
// let _ = std::io::stdout().flush();
|
||||
//
|
||||
// let mut buf = String::new();
|
||||
// let _ = std::io::stdin().read_line(&mut buf);
|
||||
//
|
||||
// if let Ok(num) = buf.trim().parse::<usize>() {
|
||||
// if let Some(g) = options.get(num) {
|
||||
// return g.clone();
|
||||
// } else {
|
||||
// return prompt_with_list_or_str(p, options);
|
||||
// }
|
||||
// } else {
|
||||
// return buf.trim().to_string();
|
||||
// }
|
||||
// }
|
||||
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub(crate) fn prompt_with_map(p: &str, options: HashMap<&str, &str>) -> String {
|
||||
println!("{c}prompt{r}: {p}",
|
||||
c=anstyle::AnsiColor::Cyan.render_fg(),
|
||||
r=anstyle::Reset.render()
|
||||
);
|
||||
|
||||
let mut keys = Vec::new();
|
||||
|
||||
for (k, v) in &options {
|
||||
println!(" - {}: {}", k, v);
|
||||
keys.push(k.trim().to_lowercase())
|
||||
}
|
||||
|
||||
print!("> ");
|
||||
|
||||
// I dont care if it fails
|
||||
let _ = std::io::stdout().flush();
|
||||
|
||||
let mut buf = String::new();
|
||||
let _ = std::io::stdin().read_line(&mut buf);
|
||||
if !keys.contains(&buf.trim().to_lowercase()) {
|
||||
return prompt_with_map(p, options);
|
||||
}
|
||||
buf.trim().to_string()
|
||||
}
|
||||
|
||||
pub fn prompt_bool(p: &str, default: Option<bool>) -> bool {
|
||||
if default == Some(true) {
|
||||
println!("{c}prompt{r}: {p} (Y/n)",
|
||||
c=anstyle::AnsiColor::Cyan.render_fg(),
|
||||
r=anstyle::Reset.render()
|
||||
);
|
||||
} else if default == Some(false) {
|
||||
println!("{c}prompt{r}: {p} (y/N)",
|
||||
c=anstyle::AnsiColor::Cyan.render_fg(),
|
||||
r=anstyle::Reset.render()
|
||||
);
|
||||
} else {
|
||||
println!("{c}prompt{r}: {p} (y/n)",
|
||||
c=anstyle::AnsiColor::Cyan.render_fg(),
|
||||
r=anstyle::Reset.render()
|
||||
);
|
||||
}
|
||||
print!("> ");
|
||||
|
||||
// I dont care if it fails
|
||||
let _ = std::io::stdout().flush();
|
||||
|
||||
let mut buf = String::new();
|
||||
let _ = std::io::stdin().read_line(&mut buf);
|
||||
|
||||
if buf.trim().is_empty() {
|
||||
match default {
|
||||
Some(true) => return true,
|
||||
Some(false) => return false,
|
||||
None => {
|
||||
return prompt_bool(p, default);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
match buf.to_lowercase().trim() {
|
||||
"y" => true,
|
||||
"n" => false,
|
||||
c => {
|
||||
log::error!("'{c}' is invalid, type y (yes) or n (no)");
|
||||
return prompt_bool(p, default);
|
||||
}
|
||||
}
|
||||
}
|
||||
55
src/ui/cli/add.rs
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
use std::str::FromStr;
|
||||
|
||||
use anyhow::bail;
|
||||
|
||||
use crate::{config::ConfigWrapper, downloader::Downloader, manifest::{song::Song, Manifest}, util::is_supported_host};
|
||||
|
||||
|
||||
|
||||
pub async fn add(cfg: &ConfigWrapper, manifest: &mut Manifest, downloader: &mut Downloader, url: &String, name: &String, playlist: &String) -> anyhow::Result<()> {
|
||||
|
||||
let mut playlists = manifest.get_playlists().keys().map(|f| f.clone()).collect::<Vec<String>>();
|
||||
|
||||
playlists.sort();
|
||||
|
||||
if !is_supported_host(url::Url::from_str(&url)?) {
|
||||
log::error!("Invalid or unsupported host name");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
|
||||
|
||||
let song = Song::from_url_str(url.clone())?;
|
||||
manifest.add_song(playlist, name.clone(), song.clone());
|
||||
manifest.save(None)?;
|
||||
|
||||
let should_download = crate::prompt::prompt_bool("Download song now?", Some(false));
|
||||
|
||||
if should_download {
|
||||
downloader.download_song(cfg, &name, &song, &playlist, manifest.get_format())?;
|
||||
crate::process_manager::wait_for_procs_untill(0)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn add_playlist(cfg: &ConfigWrapper, manifest: &mut Manifest, downloader: &mut Downloader, url: &String, name: &String) -> anyhow::Result<()> {
|
||||
let songs = downloader.download_playlist_nb(cfg, url, name, manifest.get_format())?;
|
||||
|
||||
if manifest.get_playlist(name).is_some() {
|
||||
log::error!("Playlist {name} already exists");
|
||||
bail!("")
|
||||
}
|
||||
|
||||
manifest.add_playlist(name.clone());
|
||||
|
||||
let playlist = manifest.get_playlist_mut(name).expect("Unreachable");
|
||||
|
||||
for (sname, song) in songs {
|
||||
playlist.add_song(sname, song);
|
||||
}
|
||||
manifest.save(None)?;
|
||||
|
||||
while downloader.download_all_nb_poll(cfg)?.is_some() {};
|
||||
Ok(())
|
||||
}
|
||||
46
src/ui/cli/mod.rs
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
mod add;
|
||||
|
||||
use crate::{config::{cli::CliCommand, ConfigWrapper}, downloader::Downloader, manifest::Manifest, ui::gui};
|
||||
|
||||
|
||||
|
||||
pub async fn command_run(cfg: &ConfigWrapper, manifest: &mut Manifest) -> anyhow::Result<()> {
|
||||
log::info!("Is in term: {}", cfg.isatty);
|
||||
//std::fs::write("./isatty", format!("{}\n", cfg.isatty))?;
|
||||
|
||||
let mut downloader = Downloader::new();
|
||||
match (&cfg.cli.command, cfg.isatty) {
|
||||
(None | Some(CliCommand::Download), true) => {
|
||||
match downloader.download_all(manifest, &cfg).await {
|
||||
Ok(count) => log::info!("Downloaded {count} songs"),
|
||||
Err(e) => {
|
||||
log::error!("Failed to download songs: {e}");
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
},
|
||||
(Some(c), _) => {
|
||||
match c {
|
||||
CliCommand::Download => unreachable!(),
|
||||
CliCommand::AddPlaylist { url, name } => {
|
||||
if let Err(e) = add::add_playlist(cfg, manifest, &mut downloader, url, name).await {
|
||||
log::error!("Failed to run 'add-playlist' commmand: {e}");
|
||||
}
|
||||
}
|
||||
CliCommand::Add { url, name, playlist } => {
|
||||
if let Err(e) = add::add(cfg, manifest, &mut downloader, url, name, playlist).await {
|
||||
log::error!("Failed to run 'add' command: {e}");
|
||||
}
|
||||
}
|
||||
CliCommand::Gui => {
|
||||
gui::Gui::start(manifest.clone(), downloader, cfg.clone())?;
|
||||
},
|
||||
}
|
||||
}
|
||||
(None, false) => {
|
||||
gui::Gui::start(manifest.clone(), downloader, cfg.clone())?;
|
||||
},
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
57
src/ui/gui/components/context_menu.rs
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
use egui::{Color32, RichText};
|
||||
use crate::{manifest::song::Song, ui::gui::windows::{song_edit::GuiSongEditor, WindowIndex}};
|
||||
|
||||
pub struct ContextMenu;
|
||||
|
||||
// NOTE: This should be a component but theres no easy way to do that, so we just make it folow the
|
||||
// trait manually, ish, more like a convention
|
||||
impl /* ComponentUi for */ ContextMenu {
|
||||
pub fn ui(gui: &mut crate::ui::gui::Gui, ui: &mut egui::Ui, pname: &String, sname: &String, song: &Song) {
|
||||
if ui.button("Edit").clicked() {
|
||||
let w = gui.windows.get_window::<GuiSongEditor>(WindowIndex::SongEdit);
|
||||
w.set_active_song(pname, sname, song.get_url_str());
|
||||
gui.windows.open(WindowIndex::SongEdit, true);
|
||||
ui.close_menu()
|
||||
}
|
||||
|
||||
if ui.button("Download").clicked() {
|
||||
if let Err(e) = gui.downloader.download_song_nb(&gui.cfg, pname, sname, song, gui.manifest.get_format()) {
|
||||
log::error!("{e}");
|
||||
gui.throw_error(format!("Failed to download song {sname}: {e}"));
|
||||
}
|
||||
ui.close_menu()
|
||||
}
|
||||
|
||||
if ui.button("Open Source").clicked() {
|
||||
if let Err(e) = open::that(song.get_url_str()) {
|
||||
log::error!("{e}");
|
||||
gui.throw_error(format!("Failed to open song source: {e}"));
|
||||
}
|
||||
ui.close_menu()
|
||||
}
|
||||
if ui.button("Play").clicked() {
|
||||
let p = crate::util::get_song_path(pname, sname, gui.manifest.get_format());
|
||||
|
||||
if !p.exists() {
|
||||
gui.throw_error(format!("Song does not exist on disk"));
|
||||
} else if let Err(e) = open::that(p) {
|
||||
log::error!("{e}");
|
||||
gui.throw_error(format!("Failed to play song: {e}"));
|
||||
}
|
||||
ui.close_menu()
|
||||
}
|
||||
if ui.button("Delete from disk").clicked() {
|
||||
let p = crate::util::get_song_path(pname, sname, gui.manifest.get_format());
|
||||
if p.exists() {
|
||||
if let Err(e) = std::fs::remove_file(p) {
|
||||
gui.throw_error(format!("Failed to delete file: {e}"));
|
||||
}
|
||||
}
|
||||
ui.close_menu();
|
||||
}
|
||||
if ui.button(RichText::new("Delete").color(Color32::RED)).clicked() {
|
||||
gui.throw_error("TODO");
|
||||
ui.close_menu()
|
||||
}
|
||||
}
|
||||
}
|
||||
14
src/ui/gui/components/mod.rs
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
use super::Gui;
|
||||
|
||||
|
||||
pub mod nav;
|
||||
pub mod song_list;
|
||||
pub mod context_menu;
|
||||
|
||||
pub trait Component {
|
||||
fn ui(gui: &mut Gui, ctx: &egui::Context);
|
||||
}
|
||||
|
||||
pub trait ComponentUi {
|
||||
fn ui(gui: &mut Gui, ui: &mut egui::Ui);
|
||||
}
|
||||
68
src/ui/gui/components/nav.rs
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
use crate::ui::gui::{windows::WindowIndex, Gui};
|
||||
|
||||
use super::Component;
|
||||
|
||||
|
||||
|
||||
pub struct NavBar;
|
||||
|
||||
impl Component for NavBar {
|
||||
fn ui(gui: &mut Gui, ctx: &egui::Context) {
|
||||
egui::TopBottomPanel::top("top_panel").show(ctx, |ui| {
|
||||
// The top panel is often a good place for a menu bar:
|
||||
egui::menu::bar(ui, |ui| {
|
||||
ui.menu_button("File", |ui| {
|
||||
if ui.button("Source").clicked() {
|
||||
ctx.open_url(egui::OpenUrl::new_tab("https://git.mcorangehq.xyz/XOR64/music"));
|
||||
}
|
||||
if ui.button("Save").clicked() {
|
||||
if let Err(e) = gui.manifest.save(None) {
|
||||
log::error!("Failed to save manifest: {e}");
|
||||
}
|
||||
}
|
||||
if ui.button("Quit").clicked() {
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
|
||||
}
|
||||
});
|
||||
|
||||
ui.menu_button("Song", |ui| {
|
||||
if ui.button("Add New").clicked() {
|
||||
gui.windows.open(WindowIndex::SongNew, true);
|
||||
}
|
||||
});
|
||||
|
||||
ui.menu_button("Playlist", |ui| {
|
||||
if ui.button("Import").clicked() {
|
||||
gui.windows.open(WindowIndex::ImportPlaylist, true);
|
||||
}
|
||||
});
|
||||
|
||||
ui.menu_button("Downloader", |ui| {
|
||||
if ui.button("Download All").clicked() {
|
||||
if let Err(e) = gui.downloader.download_all_nb(&gui.manifest, &gui.cfg) {
|
||||
log::error!("Err: {e}");
|
||||
}
|
||||
}
|
||||
});
|
||||
ui.add_space(16.0);
|
||||
ui.with_layout(egui::Layout::bottom_up(egui::Align::RIGHT), |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
if gui.downloader.get_songs_left_nb() > 0 {
|
||||
gui.downloading = true;
|
||||
ui.label(format!("Downloading: {}/{}", gui.downloader.get_songs_left_nb(), gui.downloader.get_initial_song_count_nb()));
|
||||
} else if gui.downloading {
|
||||
let _ = notify_rust::Notification::new()
|
||||
.summary("Done downloading")
|
||||
.body("Your music has been downloaded")
|
||||
.show();
|
||||
gui.downloading = false;
|
||||
}
|
||||
let _ = gui.downloader.download_all_nb_poll(&gui.cfg);
|
||||
egui::widgets::global_dark_light_mode_buttons(ui);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
}
|
||||
}
|
||||
130
src/ui/gui/components/song_list.rs
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
use egui::{Color32, RichText};
|
||||
use egui_extras::{Column, TableBuilder};
|
||||
|
||||
use crate::manifest::song::SongType;
|
||||
|
||||
use super::{context_menu::ContextMenu, ComponentUi};
|
||||
|
||||
|
||||
pub struct SongList;
|
||||
|
||||
impl ComponentUi for SongList {
|
||||
fn ui(gui: &mut crate::ui::gui::Gui, ui: &mut egui::Ui) {
|
||||
let fltr_by;
|
||||
let filter_clean;
|
||||
if gui.filter.starts_with("playlist:") {
|
||||
fltr_by = "playlist";
|
||||
filter_clean = gui.filter.strip_prefix("playlist:").unwrap_or("").to_string().to_lowercase();
|
||||
} else if gui.filter.starts_with("source:") {
|
||||
fltr_by = "source";
|
||||
filter_clean = gui.filter.strip_prefix("source:").unwrap_or("").to_string().to_lowercase();
|
||||
} else if gui.filter.starts_with("url:") {
|
||||
fltr_by = "url";
|
||||
filter_clean = gui.filter.strip_prefix("url:").unwrap_or("").to_string();
|
||||
} else {
|
||||
fltr_by = "";
|
||||
filter_clean = gui.filter.clone();
|
||||
}
|
||||
|
||||
ui.vertical(|ui| {
|
||||
ui.horizontal(|ui| {
|
||||
ui.colored_label(Color32::from_hex("#4444aa").unwrap(), "Filter: ");
|
||||
ui.text_edit_singleline(&mut gui.filter);
|
||||
});
|
||||
});
|
||||
|
||||
ui.vertical(|ui| {
|
||||
let available_height = ui.available_height();
|
||||
let table = TableBuilder::new(ui)
|
||||
.striped(true)
|
||||
.cell_layout(egui::Layout::left_to_right(egui::Align::Center))
|
||||
.resizable(true)
|
||||
//.column(Column::auto())
|
||||
.column(Column::auto())
|
||||
//.column(
|
||||
// Column::remainder()
|
||||
// .at_least(40.0)
|
||||
// .clip(true)
|
||||
// .resizable(true),
|
||||
//)
|
||||
.column(Column::auto())
|
||||
.column(Column::remainder())
|
||||
//.column(Column::remainder())
|
||||
.min_scrolled_height(0.0)
|
||||
.max_scroll_height(available_height)
|
||||
.sense(egui::Sense::click());
|
||||
|
||||
let playlists = gui.manifest.get_playlists().clone();
|
||||
|
||||
let songs = {
|
||||
let mut songs = Vec::new();
|
||||
for (pname, p) in playlists {
|
||||
for (sname, s) in p {
|
||||
songs.push((pname.clone(), sname, s))
|
||||
}
|
||||
}
|
||||
songs
|
||||
};
|
||||
|
||||
table.header(20.0, |mut header| {
|
||||
// header.col(|_|{});
|
||||
header.col(|ui| {
|
||||
ui.strong("Playlist");
|
||||
});
|
||||
header.col(|ui| {
|
||||
ui.strong("Source");
|
||||
});
|
||||
header.col(|ui| {
|
||||
ui.strong("Name");
|
||||
});
|
||||
}).body(|mut body| {
|
||||
for (pname, sname, s) in songs {
|
||||
if fltr_by == "playlist" && !filter_clean.is_empty() {
|
||||
if !pname.to_lowercase().contains(&filter_clean) {
|
||||
continue;
|
||||
}
|
||||
} else if fltr_by == "type" && !filter_clean.is_empty(){
|
||||
if !s.get_type().to_string().to_lowercase().contains(&filter_clean) {
|
||||
continue;
|
||||
}
|
||||
} else if fltr_by == "url" && !filter_clean.is_empty(){
|
||||
if !s.get_url_str().contains(&filter_clean) {
|
||||
continue;
|
||||
}
|
||||
} else if !filter_clean.is_empty() {
|
||||
if !sname.to_lowercase().contains(&filter_clean) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
body.row(18.0, |mut row| {
|
||||
|
||||
row.col(|ui| {
|
||||
ui.label(pname.clone())
|
||||
.context_menu(|ui| ContextMenu::ui(gui, ui, &pname, &sname, &s));
|
||||
});
|
||||
row.col(|ui| {
|
||||
let color =
|
||||
match s.get_type() {
|
||||
SongType::Youtube => Color32::from_hex("#FF0000").unwrap(),
|
||||
SongType::Spotify => Color32::from_hex("#1db954").unwrap(),
|
||||
SongType::Soundcloud => Color32::from_hex("#F26F23").unwrap()
|
||||
};
|
||||
|
||||
ui.colored_label(color, s.get_type().to_string())
|
||||
.context_menu(|ui| ContextMenu::ui(gui, ui, &pname, &sname, &s));
|
||||
});
|
||||
row.col(|ui| {
|
||||
ui.hyperlink_to(sname.clone(), s.get_url_str())
|
||||
.context_menu(|ui| ContextMenu::ui(gui, ui, &pname, &sname, &s));
|
||||
});
|
||||
|
||||
row.response()
|
||||
.context_menu(|ui| ContextMenu::ui(gui, ui, &pname, &sname, &s));
|
||||
|
||||
})
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
}
|
||||
}
|
||||
86
src/ui/gui/mod.rs
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
mod windows;
|
||||
mod components;
|
||||
|
||||
use components::{Component, ComponentUi};
|
||||
use windows::{State, WindowIndex, WindowManager};
|
||||
use crate::{config::ConfigWrapper, downloader::Downloader, manifest::Manifest};
|
||||
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub struct Gui {
|
||||
windows: WindowManager,
|
||||
manifest: Manifest,
|
||||
filter: String,
|
||||
downloader: Downloader,
|
||||
cfg: ConfigWrapper,
|
||||
downloading: bool,
|
||||
}
|
||||
|
||||
impl Gui {
|
||||
fn new(_: &eframe::CreationContext<'_>, manifest: Manifest, downloader: Downloader, cfg: ConfigWrapper) -> Self {
|
||||
Self {
|
||||
manifest,
|
||||
downloader,
|
||||
cfg,
|
||||
windows: windows::WindowManager::new(),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn start(manifest: Manifest, downloader: Downloader, cfg: ConfigWrapper) -> anyhow::Result<()> {
|
||||
let native_options = eframe::NativeOptions {
|
||||
viewport: egui::ViewportBuilder::default()
|
||||
.with_inner_size([400.0, 300.0])
|
||||
.with_min_inner_size([300.0, 220.0]),
|
||||
// .with_icon(
|
||||
// // NOTE: Adding an icon is optional
|
||||
// eframe::icon_data::from_png_bytes(&include_bytes!("../assets/icon-256.png")[..])
|
||||
// .expect("Failed to load icon"),
|
||||
// ),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
if let Err(e) = eframe::run_native(
|
||||
"eframe template",
|
||||
native_options,
|
||||
Box::new(|cc| Box::new(Gui::new(cc, manifest, downloader, cfg))),
|
||||
) {
|
||||
log::error!("Failed to create window: {e}");
|
||||
};
|
||||
|
||||
Ok(())
|
||||
}
|
||||
pub fn throw_error(&mut self, text: impl ToString) {
|
||||
let w = self.windows.get_window::<windows::error::GuiError>(WindowIndex::Error);
|
||||
w.set_error_message(text);
|
||||
self.windows.open(WindowIndex::Error, true);
|
||||
}
|
||||
}
|
||||
|
||||
impl eframe::App for Gui {
|
||||
fn update(&mut self, ctx: &egui::Context, _: &mut eframe::Frame) {
|
||||
components::nav::NavBar::ui(self, ctx);
|
||||
{
|
||||
let mut state = State {
|
||||
cfg: self.cfg.clone(),
|
||||
downloader: self.downloader.clone(),
|
||||
manifest: self.manifest.clone(),
|
||||
};
|
||||
self.windows.ui(&mut state, ctx).unwrap();
|
||||
self.cfg = state.cfg;
|
||||
self.downloader = state.downloader;
|
||||
self.manifest = state.manifest;
|
||||
}
|
||||
|
||||
egui::CentralPanel::default().show(ctx, |ui| {
|
||||
// The central panel the region left after adding TopPanel's and SidePanel's
|
||||
//ui.heading(format!("Songs ({})", self.manifest.get_song_count()));
|
||||
components::song_list::SongList::ui(self, ui);
|
||||
ui.separator();
|
||||
|
||||
ui.with_layout(egui::Layout::bottom_up(egui::Align::LEFT), |ui| {
|
||||
egui::warn_if_debug_build(ui);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
32
src/ui/gui/windows/error.rs
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
use egui::{Color32, Label, RichText};
|
||||
|
||||
use super::{State, Window};
|
||||
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub struct GuiError {
|
||||
text: String,
|
||||
}
|
||||
|
||||
|
||||
impl Window for GuiError {
|
||||
fn ui(&mut self, _: &mut State, ctx: &egui::Context, open: &mut bool) -> anyhow::Result<()> {
|
||||
egui::Window::new("ERROR!!!! D:")
|
||||
.open(open)
|
||||
.show(ctx, |ui| {
|
||||
ui.vertical(|ui| {
|
||||
ui.label(RichText::new("Error:").size(30.0).color(Color32::RED));
|
||||
ui.horizontal(|ui| {
|
||||
ui.add(Label::new(self.text.clone()).wrap(true));
|
||||
})
|
||||
})
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl GuiError {
|
||||
pub fn set_error_message<S: ToString>(&mut self, text: S) {
|
||||
self.text = text.to_string();
|
||||
}
|
||||
}
|
||||
59
src/ui/gui/windows/import_playlist.rs
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
use super::{State, Window};
|
||||
|
||||
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub struct GuiImportPlaylist {
|
||||
ed_name: String,
|
||||
ed_url: String,
|
||||
}
|
||||
|
||||
|
||||
impl Window for GuiImportPlaylist {
|
||||
fn ui(&mut self, state: &mut State, ctx: &egui::Context, open: &mut bool) -> anyhow::Result<()> {
|
||||
let mut save = false;
|
||||
egui::Window::new("Import Playlist")
|
||||
.open(open)
|
||||
.show(ctx, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("Type: Youtube");
|
||||
});
|
||||
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("Name: ");
|
||||
ui.text_edit_singleline(&mut self.ed_name);
|
||||
});
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("Url: ");
|
||||
ui.text_edit_singleline(&mut self.ed_url);
|
||||
});
|
||||
|
||||
if ui.button("Import").clicked() {
|
||||
save = true;
|
||||
}
|
||||
});
|
||||
|
||||
if save {
|
||||
let name = self.ed_name.clone();
|
||||
let url = self.ed_url.clone();
|
||||
|
||||
if state.manifest.get_playlist(&name).is_some() {
|
||||
log::error!("Playlist {name} already exists");
|
||||
}
|
||||
|
||||
let songs = state.downloader.download_playlist_nb(&state.cfg, &url, &name, &state.manifest.get_format()).unwrap();
|
||||
state.manifest.add_playlist(name.clone());
|
||||
|
||||
let playlist = state.manifest.get_playlist_mut(&name).expect("Unreachable");
|
||||
|
||||
for (sname, song) in songs {
|
||||
log::info!("Added: {sname}");
|
||||
playlist.add_song(sname, song);
|
||||
}
|
||||
let _ = state.manifest.save(None);
|
||||
*open = false;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
76
src/ui/gui/windows/mod.rs
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
use std::collections::HashMap;
|
||||
use crate::{config::ConfigWrapper, downloader::Downloader, manifest::Manifest};
|
||||
|
||||
pub mod song_edit;
|
||||
pub mod error;
|
||||
pub mod import_playlist;
|
||||
pub mod song_new;
|
||||
|
||||
pub trait Window: std::fmt::Debug {
|
||||
fn ui(&mut self, state: &mut State, ctx: &egui::Context, open: &mut bool) -> anyhow::Result<()>;
|
||||
}
|
||||
|
||||
#[derive(Debug, Hash, Eq, PartialEq, Clone, Copy)]
|
||||
pub enum WindowIndex {
|
||||
Error,
|
||||
ImportPlaylist,
|
||||
SongEdit,
|
||||
SongNew,
|
||||
}
|
||||
|
||||
|
||||
#[derive(Debug,Default)]
|
||||
pub struct WindowManager {
|
||||
opened: HashMap<WindowIndex, bool>,
|
||||
windows: HashMap<WindowIndex, Box<dyn Window>>
|
||||
}
|
||||
|
||||
pub struct State {
|
||||
pub downloader: Downloader,
|
||||
pub manifest: Manifest,
|
||||
pub cfg: ConfigWrapper,
|
||||
}
|
||||
|
||||
impl WindowManager {
|
||||
pub fn new() -> Self {
|
||||
let mut windows: HashMap<WindowIndex, Box<dyn Window>> = HashMap::new();
|
||||
windows.insert(WindowIndex::Error, Box::<error::GuiError>::default());
|
||||
windows.insert(WindowIndex::ImportPlaylist, Box::<import_playlist::GuiImportPlaylist>::default());
|
||||
windows.insert(WindowIndex::SongEdit, Box::<song_edit::GuiSongEditor>::default());
|
||||
windows.insert(WindowIndex::SongNew, Box::<song_new::GuiNewSong>::default());
|
||||
Self {
|
||||
windows,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn is_open(&self, id: &WindowIndex) -> bool {
|
||||
*self.opened.get(id).unwrap()
|
||||
}
|
||||
|
||||
pub fn open(&mut self, id: WindowIndex, open: bool) {
|
||||
self.opened.insert(id, open);
|
||||
}
|
||||
|
||||
pub fn ui(&mut self, state: &mut State, ctx: &egui::Context) -> anyhow::Result<()> {
|
||||
for (id, window) in &mut self.windows {
|
||||
if !self.opened.contains_key(&id) {
|
||||
self.opened.insert(*id, false);
|
||||
}
|
||||
let open = self.opened.get_mut(id).unwrap();
|
||||
if let Err(e) = window.ui(state, ctx, open) {
|
||||
log::error!("Window {id:?} errored: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn get_window<T: Window + 'static>(&mut self, id: WindowIndex) -> &mut Box<T> {
|
||||
let w = self.windows.get_mut(&id).unwrap();
|
||||
unsafe {
|
||||
crate::util::as_any_mut(w).downcast_mut_unchecked()
|
||||
}
|
||||
}
|
||||
}
|
||||
95
src/ui/gui/windows/song_edit.rs
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
use anyhow::{Result, bail};
|
||||
use egui::Color32;
|
||||
use crate::ui::gui::Gui;
|
||||
|
||||
use super::{State, Window};
|
||||
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub struct GuiSongEditor {
|
||||
song: (String, String),
|
||||
ed_url: String,
|
||||
ed_name: String,
|
||||
}
|
||||
|
||||
|
||||
impl Window for GuiSongEditor {
|
||||
fn ui(&mut self, state: &mut State, ctx: &egui::Context, open: &mut bool) -> anyhow::Result<()> {
|
||||
let mut save = false;
|
||||
let (playlist, song_name) = self.song.clone();
|
||||
|
||||
if playlist.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let Some(song) = state.manifest.get_song(&playlist, &song_name) else {
|
||||
bail!("Failed to get song (1)");
|
||||
};
|
||||
let song = song.clone();
|
||||
|
||||
egui::Window::new("Song editor")
|
||||
.open(open)
|
||||
.show(ctx,
|
||||
|ui| {
|
||||
|
||||
ui.horizontal(|ui| {
|
||||
ui.spacing_mut().item_spacing.x = 0.0;
|
||||
ui.label("[");
|
||||
ui.hyperlink_to("link", song.get_url().unwrap());
|
||||
ui.label("] ");
|
||||
ui.colored_label(Color32::LIGHT_BLUE, &playlist);
|
||||
ui.label(": ");
|
||||
ui.label(&song_name)
|
||||
});
|
||||
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("Type: ");
|
||||
ui.label(&song.get_type().to_string());
|
||||
});
|
||||
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("Name: ");
|
||||
ui.text_edit_singleline(&mut self.ed_name);
|
||||
});
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("Url: ");
|
||||
ui.text_edit_singleline(&mut self.ed_url);
|
||||
});
|
||||
|
||||
if ui.button("Save").clicked() {
|
||||
save = true;
|
||||
}
|
||||
});
|
||||
|
||||
if save {
|
||||
{
|
||||
let Some(song) = state.manifest.get_song_mut(&playlist, &song_name) else {
|
||||
bail!("Failed to get song (2)");
|
||||
};
|
||||
|
||||
*song.get_url_str_mut() = self.ed_url.clone();
|
||||
}
|
||||
|
||||
let Some(playlist) = state.manifest.get_playlist_mut(&playlist) else {
|
||||
bail!("Failed to get playlist");
|
||||
};
|
||||
|
||||
|
||||
playlist.remove_song(&song_name);
|
||||
playlist.add_song(self.ed_name.clone(), song);
|
||||
*open = false;
|
||||
let _ = state.manifest.save(None);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl GuiSongEditor {
|
||||
pub fn set_active_song(&mut self, pname: &String, sname: &String, url: &String) {
|
||||
self.song.0 = pname.clone();
|
||||
self.song.1 = sname.clone();
|
||||
self.ed_name = sname.clone();
|
||||
self.ed_url = url.clone();
|
||||
}
|
||||
}
|
||||
72
src/ui/gui/windows/song_new.rs
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
use crate::manifest::song::{Song, SongType};
|
||||
use super::{State, Window};
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub struct GuiNewSong {
|
||||
ed_type: SongType,
|
||||
ed_name: String,
|
||||
ed_playlist: Option<String>,
|
||||
ed_url: String,
|
||||
}
|
||||
|
||||
impl Window for GuiNewSong {
|
||||
fn ui(&mut self, state: &mut State, ctx: &egui::Context, open: &mut bool) -> anyhow::Result<()> {
|
||||
let mut save = false;
|
||||
egui::Window::new("New song")
|
||||
.open(open)
|
||||
.show(ctx, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("Type: ");
|
||||
egui::ComboBox::from_id_source("new_song_window_type")
|
||||
.selected_text(format!("{:?}", self.ed_type))
|
||||
.show_ui(ui, |ui| {
|
||||
ui.selectable_value(&mut self.ed_type, SongType::Youtube, "Youtube");
|
||||
ui.selectable_value(&mut self.ed_type, SongType::Spotify, "Spotify");
|
||||
ui.selectable_value(&mut self.ed_type, SongType::Soundcloud, "Soundcloud");
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("Name: ");
|
||||
ui.text_edit_singleline(&mut self.ed_name);
|
||||
});
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("Playlist: ");
|
||||
egui::ComboBox::from_id_source("new_song_window_playlist")
|
||||
.selected_text(format!("{}", self.ed_playlist.clone().unwrap_or("".to_string())))
|
||||
.show_ui(ui, |ui| {
|
||||
for p in state.manifest.get_playlists().keys() {
|
||||
ui.selectable_value(&mut self.ed_playlist, Option::Some(p.clone()), p.as_str());
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("Url: ");
|
||||
ui.text_edit_singleline(&mut self.ed_url);
|
||||
});
|
||||
|
||||
if ui.button("Save").clicked() {
|
||||
save = true;
|
||||
}
|
||||
});
|
||||
|
||||
if save {
|
||||
let Some(playlist) = state.manifest.get_playlist_mut(&self.ed_playlist.clone().unwrap()) else {
|
||||
panic!("couldnt find playlist from a preset playlist list????????????");
|
||||
};
|
||||
|
||||
playlist.add_song(
|
||||
self.ed_name.clone(),
|
||||
Song::from_url_str(self.ed_url.clone()).unwrap().set_type(self.ed_type.clone()).clone()
|
||||
);
|
||||
|
||||
|
||||
let _ = state.manifest.save(None);
|
||||
*open = false;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
2
src/ui/mod.rs
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
pub mod gui;
|
||||
pub mod cli;
|
||||
77
src/util.rs
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
use std::{any::Any, path::PathBuf};
|
||||
|
||||
use crate::{constants, manifest::Format};
|
||||
|
||||
pub(crate) fn is_supported_host(url: url::Url) -> bool {
|
||||
let host = url.host_str();
|
||||
if host.is_none() {
|
||||
return false;
|
||||
}
|
||||
match host.unwrap() {
|
||||
"youtube.com" | "youtu.be" |
|
||||
"open.spotify.com" => true,
|
||||
_ => false
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn is_program_in_path(program: &str) -> Option<PathBuf> {
|
||||
if let Ok(path) = std::env::var("PATH") {
|
||||
for p in path.split(constants::PATH_VAR_SEP) {
|
||||
let exec_path = PathBuf::from(p).join(program).with_extension(constants::EXEC_EXT);
|
||||
if std::fs::metadata(&exec_path).is_ok() {
|
||||
return Some(exec_path);
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(target_family="unix")]
|
||||
pub(crate) fn isatty() -> bool {
|
||||
use std::{ffi::c_int, os::fd::AsRawFd};
|
||||
unsafe {
|
||||
let fd = std::io::stdin().as_raw_fd();
|
||||
libc::isatty(fd as c_int) == 1
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_family="windows")]
|
||||
pub(crate) fn isatty() -> bool {
|
||||
unsafe {
|
||||
use windows::Win32::System::Console;
|
||||
use Console::{CONSOLE_MODE, STD_OUTPUT_HANDLE};
|
||||
let Ok(handle) = Console::GetStdHandle(STD_OUTPUT_HANDLE) else {
|
||||
return false;
|
||||
};
|
||||
|
||||
let mut out = CONSOLE_MODE(0);
|
||||
|
||||
let ret = Console::GetConsoleMode(handle, &mut out);
|
||||
|
||||
ret.is_ok()
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn as_any_mut<T: Any>(val: &mut T) -> &mut dyn Any {
|
||||
val as &mut dyn Any
|
||||
}
|
||||
|
||||
pub fn get_song_path/*<P: TryInto<PathBuf>>*/(/*basepath: Option<P>,*/ pname: &String, sname: &String, format: &Format) -> PathBuf {
|
||||
// let mut path: PathBuf;
|
||||
/*if let Some(bp) = basepath {
|
||||
if let Ok(bp) = bp.try_into() {
|
||||
path = bp;
|
||||
} else {
|
||||
path = std::env::current_dir().unwrap_or(PathBuf::new());
|
||||
}
|
||||
} else {*/
|
||||
let mut path = std::env::current_dir().unwrap_or(PathBuf::new());
|
||||
//}
|
||||
// TODO: Get this from cfg
|
||||
path.push("out");
|
||||
path.push(pname);
|
||||
path.push(sname);
|
||||
path.set_extension(format.to_string());
|
||||
path
|
||||
}
|
||||
|
|
@ -1,31 +0,0 @@
|
|||
[package]
|
||||
name = "xmpd-cache"
|
||||
edition.workspace = true
|
||||
readme="README.md"
|
||||
authors.workspace = true
|
||||
version.workspace = true
|
||||
repository.workspace = true
|
||||
license.workspace = true
|
||||
autobins = false
|
||||
autotests = false
|
||||
autoexamples = false
|
||||
|
||||
[features]
|
||||
default = []
|
||||
|
||||
[lib]
|
||||
crate-type = ["rlib"]
|
||||
bench = false
|
||||
|
||||
[dependencies]
|
||||
xmpd-settings.path = "../xmpd-settings"
|
||||
xmpd-manifest.path = "../xmpd-manifest"
|
||||
xmpd-cliargs.path = "../xmpd-cliargs"
|
||||
anyhow.workspace = true
|
||||
camino.workspace = true
|
||||
lazy_static.workspace = true
|
||||
log.workspace = true
|
||||
uuid.workspace = true
|
||||
reqwest.workspace = true
|
||||
url.workspace = true
|
||||
image.workspace = true
|
||||
|
|
@ -1,134 +0,0 @@
|
|||
use std::{collections::HashMap, ffi::OsStr, io::{BufReader, Cursor}, path::PathBuf, process::{Command, Stdio}, str::FromStr, sync::{Arc, Mutex, MutexGuard}};
|
||||
|
||||
use anyhow::anyhow;
|
||||
use image::ImageReader;
|
||||
use xmpd_manifest::song::{IconType, Song, SourceType};
|
||||
|
||||
use crate::{downloader::song::SongStatus, DlStatus};
|
||||
|
||||
lazy_static::lazy_static!(
|
||||
static ref ICON_CACHE_DL: Arc<Mutex<IconCacheDl>> = Arc::default();
|
||||
);
|
||||
|
||||
#[derive(Debug, Default, Clone)]
|
||||
pub struct IconCacheDl {
|
||||
pub jobs: HashMap<uuid::Uuid, DlStatus>,
|
||||
pub current_jobs: usize,
|
||||
}
|
||||
|
||||
impl IconCacheDl {
|
||||
pub fn get() -> crate::Result<MutexGuard<'static, Self>> {
|
||||
match ICON_CACHE_DL.lock() {
|
||||
Ok(v) => Ok(v),
|
||||
Err(e) => anyhow::bail!(format!("{e:?}"))
|
||||
}
|
||||
}
|
||||
pub fn is_job_list_full(&self) -> bool {
|
||||
self.current_jobs >= 5
|
||||
}
|
||||
|
||||
pub fn download(&mut self, sid: uuid::Uuid, song: Song) -> crate::Result<()> {
|
||||
match song.icon_type().clone() {
|
||||
IconType::FromSource => {
|
||||
let settings = xmpd_settings::Settings::get()?.clone();
|
||||
let tooling = settings.tooling.clone();
|
||||
match song.source_type() {
|
||||
SourceType::Youtube => {
|
||||
self.jobs.insert(sid.clone(), DlStatus::Downloading);
|
||||
let mut path = settings.cache_settings.cache_path.clone();
|
||||
path.push("icons");
|
||||
path.push(sid.to_string());
|
||||
|
||||
let mut cmd = Command::new(tooling.ytdlp_path);
|
||||
cmd.arg(song.url().to_string());
|
||||
cmd.arg("-o");
|
||||
cmd.arg(&path);
|
||||
cmd.args(["--write-thumbnail", "--skip-download"]);
|
||||
if xmpd_cliargs::CLIARGS.debug {
|
||||
cmd.stdout(Stdio::piped());
|
||||
cmd.stderr(Stdio::piped());
|
||||
} else {
|
||||
cmd.stdout(Stdio::null());
|
||||
cmd.stderr(Stdio::null());
|
||||
}
|
||||
let child = cmd.spawn()?;
|
||||
std::thread::spawn(move || {
|
||||
if let Ok(output) = child.wait_with_output() {
|
||||
for line in String::from_utf8(output.stdout).unwrap().lines() {
|
||||
log::info!("CMD: {}", line);
|
||||
}
|
||||
for line in String::from_utf8(output.stderr).unwrap().lines() {
|
||||
log::error!("CMD: {}", line);
|
||||
}
|
||||
}
|
||||
let old_p = path.with_extension("webp"); // Default for yt-dlp
|
||||
let new_p = path.with_extension("png"); // Default for all
|
||||
let old_img = ImageReader::open(&old_p).unwrap().decode().unwrap();
|
||||
old_img.save(&new_p).unwrap();
|
||||
std::fs::remove_file(old_p).unwrap();
|
||||
let mut cache = IconCacheDl::get().unwrap();
|
||||
cache.jobs.insert(sid, DlStatus::Done(Some(new_p.into())));
|
||||
});
|
||||
}
|
||||
SourceType::Spotify => {
|
||||
todo!()
|
||||
}
|
||||
SourceType::Soundcloud => {
|
||||
todo!()
|
||||
}
|
||||
_ => (),
|
||||
}
|
||||
}
|
||||
IconType::CustomUrl(url) => self.download_custom_url_icon(&sid, &url)?,
|
||||
IconType::None => ()
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn download_custom_url_icon(&mut self, sid: &uuid::Uuid, url: &url::Url) -> crate::Result<()> {
|
||||
self.jobs.insert(sid.clone(), DlStatus::Downloading);
|
||||
let url_p = PathBuf::from_str(url.path())?;
|
||||
let Some(ext) = url_p.extension() else {
|
||||
anyhow::bail!("Url without extension, cant continue");
|
||||
};
|
||||
let ext = ext.to_string_lossy().to_string();
|
||||
let mut path = xmpd_settings::Settings::get()?.clone().cache_settings.cache_path;
|
||||
path.push("icons");
|
||||
path.push(sid.to_string());
|
||||
path.set_extension(ext);
|
||||
let sid = sid.clone();
|
||||
let url = url.clone();
|
||||
std::thread::spawn(move || {
|
||||
match reqwest::blocking::get(url.clone()) {
|
||||
Ok(v) => {
|
||||
match v.bytes() {
|
||||
Ok(bytes) => {
|
||||
if let Err(e) = std::fs::write(path, bytes) {
|
||||
if let Ok(mut cache) = IconCacheDl::get() {
|
||||
if let Some(job) = cache.jobs.get_mut(&sid) {
|
||||
*job = DlStatus::Error(file!(), line!() as usize, format!("{e:?}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
if let Ok(mut cache) = IconCacheDl::get() {
|
||||
if let Some(job) = cache.jobs.get_mut(&sid) {
|
||||
*job = DlStatus::Error(file!(), line!() as usize, format!("{e:?}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
if let Ok(mut cache) = IconCacheDl::get() {
|
||||
if let Some(job) = cache.jobs.get_mut(&sid) {
|
||||
*job = DlStatus::Error(file!(), line!() as usize, format!("{e:?}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
|
@ -1,4 +0,0 @@
|
|||
|
||||
pub mod song;
|
||||
pub mod icon;
|
||||
pub mod metadata;
|
||||
|
|
@ -1,162 +0,0 @@
|
|||
use std::{collections::HashMap, ffi::OsStr, process::{Command, Stdio}, sync::{Arc, Mutex, MutexGuard}};
|
||||
use camino::{Utf8Path, Utf8PathBuf};
|
||||
use xmpd_manifest::song::{Song, SourceType};
|
||||
|
||||
|
||||
lazy_static::lazy_static!(
|
||||
static ref SONG_CACHE_DL: Arc<Mutex<SongCacheDl>> = Arc::default();
|
||||
);
|
||||
|
||||
#[derive(Debug, Clone, Hash, PartialEq, PartialOrd, Eq, Ord)]
|
||||
pub enum SongStatus {
|
||||
Downloading,
|
||||
Converting,
|
||||
Failed(String),
|
||||
Done
|
||||
}
|
||||
|
||||
#[cfg(target_family = "windows")]
|
||||
const PATH_SEP: char = ';';
|
||||
#[cfg(target_family = "unix")]
|
||||
const PATH_SEP: char = ':';
|
||||
|
||||
|
||||
#[derive(Debug, Default, Clone)]
|
||||
pub struct SongCacheDl {
|
||||
pub jobs: HashMap<uuid::Uuid, SongStatus>,
|
||||
pub current_jobs: usize,
|
||||
}
|
||||
|
||||
impl SongCacheDl {
|
||||
pub fn get() -> crate::Result<MutexGuard<'static, Self>> {
|
||||
match SONG_CACHE_DL.lock() {
|
||||
Ok(v) => Ok(v),
|
||||
Err(e) => anyhow::bail!(format!("{e:?}"))
|
||||
}
|
||||
}
|
||||
pub fn is_job_list_full(&self) -> bool {
|
||||
self.current_jobs >= 5
|
||||
}
|
||||
|
||||
|
||||
|
||||
pub fn download(&mut self, sid: uuid::Uuid, song: Song) -> crate::Result<()> {
|
||||
self.current_jobs += 1;
|
||||
let song_format = xmpd_settings::Settings::get().unwrap().tooling.song_format.clone();
|
||||
let settings = xmpd_settings::Settings::get()?.clone();
|
||||
let tooling = settings.tooling.clone();
|
||||
let mut song_cache_d = settings.cache_settings.cache_path.clone();
|
||||
song_cache_d.push("songs");
|
||||
match song.source_type() {
|
||||
SourceType::Youtube |
|
||||
SourceType::Soundcloud => {
|
||||
let mut song_p = song_cache_d.clone();
|
||||
song_p.push(sid.to_string());
|
||||
let song_p = song_p.with_extension(&song_format);
|
||||
|
||||
let mut dl_cmd = Command::new(&tooling.ytdlp_path);
|
||||
dl_cmd.arg(song.url().as_str());
|
||||
dl_cmd.args(["-x", "--audio-format", &song_format]);
|
||||
dl_cmd.arg("-o");
|
||||
dl_cmd.arg(&song_p);
|
||||
|
||||
if xmpd_cliargs::CLIARGS.debug {
|
||||
dl_cmd.stdout(Stdio::piped());
|
||||
dl_cmd.stderr(Stdio::piped());
|
||||
} else {
|
||||
dl_cmd.stdout(Stdio::null());
|
||||
dl_cmd.stderr(Stdio::null());
|
||||
}
|
||||
let dl_child = dl_cmd.spawn()?;
|
||||
self.jobs.insert(sid, SongStatus::Downloading);
|
||||
std::thread::spawn(move || {
|
||||
let mut err_line = String::new();
|
||||
if let Ok(output) = dl_child.wait_with_output() {
|
||||
for line in String::from_utf8(output.stdout).unwrap().lines() {
|
||||
if line.contains("ERROR") {
|
||||
err_line = line.to_string();
|
||||
}
|
||||
log::info!("CMD: {}", line);
|
||||
}
|
||||
for line in String::from_utf8(output.stderr).unwrap().lines() {
|
||||
if line.contains("ERROR") {
|
||||
err_line = line.to_string();
|
||||
}
|
||||
log::error!("CMD: {}", line);
|
||||
}
|
||||
}
|
||||
let mut cache = SONG_CACHE_DL.lock().unwrap();
|
||||
if song_p.exists() {
|
||||
cache.jobs.insert(sid, SongStatus::Done);
|
||||
} else {
|
||||
cache.jobs.insert(sid, SongStatus::Failed(err_line));
|
||||
}
|
||||
cache.current_jobs -= 1;
|
||||
});
|
||||
}
|
||||
|
||||
SourceType::Spotify => {
|
||||
// Spotdl doesnt have webm as a format so its fucking annoying, oh well
|
||||
let mut song_p = song_cache_d.clone();
|
||||
song_p.push(sid.to_string());
|
||||
song_p.push("{output-ext}");
|
||||
let mut dl_cmd = Command::new(&tooling.spotdl_path);
|
||||
dl_cmd.arg(song.url().as_str());
|
||||
dl_cmd.arg("--ffmpeg");
|
||||
dl_cmd.arg(&tooling.ffmpeg_path);
|
||||
dl_cmd.args(["--format", &song_format, "--output"]);
|
||||
dl_cmd.arg(&song_p);
|
||||
let arg_str = dl_cmd.get_args();
|
||||
let arg_str: Vec<_> = arg_str.collect();
|
||||
let arg_str = arg_str.join(OsStr::new(" ")).to_string_lossy().to_string();
|
||||
log::debug!("spotify cli: {} {}", tooling.spotdl_path, arg_str);
|
||||
if xmpd_cliargs::CLIARGS.debug {
|
||||
dl_cmd.stdout(Stdio::piped());
|
||||
dl_cmd.stderr(Stdio::piped());
|
||||
} else {
|
||||
dl_cmd.stdout(Stdio::null());
|
||||
dl_cmd.stderr(Stdio::null());
|
||||
}
|
||||
let child = dl_cmd.spawn()?;
|
||||
self.jobs.insert(sid, SongStatus::Downloading);
|
||||
std::thread::spawn(move || {
|
||||
if let Ok(output) = child.wait_with_output() {
|
||||
for line in String::from_utf8(output.stdout).unwrap().lines() {
|
||||
log::info!("CMD: {}", line);
|
||||
}
|
||||
for line in String::from_utf8(output.stderr).unwrap().lines() {
|
||||
log::error!("CMD: {}", line);
|
||||
}
|
||||
}
|
||||
let mut from = song_p.clone();
|
||||
from.pop();
|
||||
from.push(format!("{song_format}.{song_format}"));
|
||||
|
||||
let mut to = song_p.clone();
|
||||
to.pop();
|
||||
to.set_extension(&song_format);
|
||||
log::debug!("from: {from:?} to: {to:?}");
|
||||
std::fs::copy(&from, &to).unwrap();
|
||||
from.pop();
|
||||
let mut cache = SONG_CACHE_DL.lock().unwrap();
|
||||
if let Err(_) = std::fs::remove_dir_all(from) {
|
||||
cache.jobs.insert(sid, SongStatus::Failed(String::from("Unknown")));
|
||||
} else {
|
||||
cache.jobs.insert(sid, SongStatus::Done);
|
||||
}
|
||||
cache.current_jobs -= 1;
|
||||
});
|
||||
}
|
||||
SourceType::HttpBare => {
|
||||
todo!()
|
||||
}
|
||||
SourceType::Http7z => {
|
||||
todo!()
|
||||
}
|
||||
SourceType::HttpZip => {
|
||||
todo!()
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
|
@ -1,212 +0,0 @@
|
|||
use std::{collections::HashMap, path::PathBuf, str::FromStr, sync::{mpsc::{self, Receiver, Sender}, Arc, Mutex, MutexGuard}, time::Duration};
|
||||
use anyhow::anyhow;
|
||||
use camino::{Utf8Path, Utf8PathBuf};
|
||||
use downloader::song::SongStatus;
|
||||
use xmpd_manifest::song::Song;
|
||||
|
||||
pub mod downloader;
|
||||
|
||||
type Result<T> = anyhow::Result<T>;
|
||||
|
||||
lazy_static::lazy_static!(
|
||||
static ref CACHE: Arc<Mutex<Cache>> = Arc::new(Mutex::new(Cache::default()));
|
||||
);
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub struct Cache {
|
||||
cache_dir: camino::Utf8PathBuf,
|
||||
song_cache: HashMap<uuid::Uuid, DlStatus>,
|
||||
icon_cache: HashMap<uuid::Uuid, DlStatus>,
|
||||
song_queue: Vec<(uuid::Uuid, Song)>,
|
||||
icon_queue: Vec<(uuid::Uuid, Song)>,
|
||||
//meta_queue: Vec<(uuid::Uuid, Song)>
|
||||
// TODO: Add Icon, metadata cache
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum DlStatus {
|
||||
Done(Option<PathBuf>),
|
||||
Downloading,
|
||||
Error(&'static str, usize, String),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum Message {
|
||||
DownloadDone(uuid::Uuid),
|
||||
Error(&'static str, usize, String)
|
||||
}
|
||||
|
||||
impl Cache {
|
||||
pub fn get() -> crate::Result<MutexGuard<'static, Self>> {
|
||||
match CACHE.lock() {
|
||||
Ok(l) => Ok(l),
|
||||
Err(e) => Err(anyhow::anyhow!(format!("{e:?}"))),
|
||||
}
|
||||
}
|
||||
fn check_if_tool_exists(&self, tool_path: &Utf8Path) -> crate::Result<()> {
|
||||
if std::fs::metadata(tool_path).is_ok() {
|
||||
return Ok(());
|
||||
}
|
||||
if let Ok(path) = std::env::var("PATH") {
|
||||
for p in path.split(":") {
|
||||
let p_str = Utf8PathBuf::from(p).join(tool_path);
|
||||
if std::fs::metadata(p_str).is_ok() {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
anyhow::bail!("Tool {} was not found", tool_path)
|
||||
}
|
||||
pub fn init(&mut self) -> Result<Receiver<Message>> {
|
||||
// Check for missing tooling
|
||||
|
||||
let tooling = xmpd_settings::Settings::get()?.tooling.clone();
|
||||
self.check_if_tool_exists(&tooling.ytdlp_path)?;
|
||||
self.check_if_tool_exists(&tooling.spotdl_path)?;
|
||||
self.check_if_tool_exists(&tooling.ffmpeg_path)?;
|
||||
|
||||
|
||||
|
||||
let (internal_tx, cache_rx) = mpsc::channel::<Message>();
|
||||
// let (internal_rx, cache_tx) = mpsc::channel::<Message>();
|
||||
start_cache_mv_thread(internal_tx);
|
||||
self.cache_dir = xmpd_settings::Settings::get()?.cache_settings.cache_path.clone();
|
||||
std::fs::create_dir_all(&self.cache_dir)?;
|
||||
{ // Get cached songs
|
||||
let mut song_cache_dir = self.cache_dir.clone();
|
||||
song_cache_dir.push("songs");
|
||||
std::fs::create_dir_all(&song_cache_dir)?;
|
||||
for file in song_cache_dir.read_dir_utf8().map_err(|e| anyhow!("failed to read cache dir: {e}"))? {
|
||||
if let Ok(file) = file {
|
||||
if !file.file_type()?.is_file() {
|
||||
log::warn!("Non song file in: {}", file.path());
|
||||
continue;
|
||||
}
|
||||
let file_path = file.path();
|
||||
let file2 = file_path.with_extension("");
|
||||
if let Some(file_name) = file2.file_name() {
|
||||
let id = uuid::Uuid::from_str(file_name)?;
|
||||
log::debug!("Found song {id}");
|
||||
// TODO: Check if id is in manifest
|
||||
self.song_cache.insert(id, DlStatus::Done(Some(file_path.into())));
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
{ // Get cached icons
|
||||
}
|
||||
{ // Get Cached meta
|
||||
}
|
||||
Ok(cache_rx)
|
||||
}
|
||||
|
||||
pub fn download_song_to_cache(&mut self, sid: uuid::Uuid, song: Song) {
|
||||
let song_format = xmpd_settings::Settings::get().unwrap().tooling.song_format.clone();
|
||||
let mut p = self.cache_dir.clone();
|
||||
p.push("songs");
|
||||
p.push(format!("{sid}.{song_format}"));
|
||||
if !p.exists() {
|
||||
log::info!("p: {p:?}");
|
||||
self.song_queue.push((sid, song));
|
||||
self.song_cache.insert(sid, DlStatus::Downloading);
|
||||
}
|
||||
}
|
||||
pub fn download_icon_to_cache(&mut self, sid: uuid::Uuid, song: Song) {
|
||||
self.icon_queue.push((sid, song));
|
||||
self.icon_cache.insert(sid, DlStatus::Downloading);
|
||||
}
|
||||
|
||||
pub fn get_cached_song_status(&mut self, sid: &uuid::Uuid) -> Option<DlStatus> {
|
||||
let original = self.song_cache.get(sid)?.clone();
|
||||
Some(original)
|
||||
}
|
||||
pub fn get_cached_icon_status(&mut self, sid: &uuid::Uuid) -> Option<DlStatus> {
|
||||
let original = self.icon_cache.get(sid)?.clone();
|
||||
Some(original)
|
||||
}
|
||||
}
|
||||
|
||||
macro_rules! he {
|
||||
($tx:expr_2021, $val:expr_2021) => {
|
||||
match $val {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
let _ = $tx.send(Message::Error(std::file!(), std::line!() as usize, format!("{e:?}")));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
fn start_cache_mv_thread(tx: Sender<Message>) {
|
||||
std::thread::spawn(move || {
|
||||
loop {
|
||||
{
|
||||
std::thread::sleep(Duration::from_millis(500));
|
||||
let song_format = he!(tx, xmpd_settings::Settings::get()).tooling.song_format.clone();
|
||||
let mut done_jobs = Vec::new();
|
||||
let mut dlc = he!(tx, downloader::song::SongCacheDl::get());
|
||||
for (sid, status) in &dlc.jobs {
|
||||
if *status == SongStatus::Done {
|
||||
let mut cache = he!(tx, CACHE.lock());
|
||||
let mut song_p = he!(tx, xmpd_settings::Settings::get()).cache_settings.cache_path.clone();
|
||||
song_p.push("songs");
|
||||
song_p.push(sid.clone().to_string());
|
||||
let song_p = song_p.with_extension(&song_format);
|
||||
if song_p.exists() {
|
||||
let _ = tx.send(Message::DownloadDone(sid.clone()));
|
||||
cache.song_cache.insert(sid.clone(), DlStatus::Done(Some(song_p.into())));
|
||||
done_jobs.push(sid.clone());
|
||||
}
|
||||
} else if let SongStatus::Failed(e) = status {
|
||||
let mut cache = he!(tx, CACHE.lock());
|
||||
let _ = tx.send(Message::Error(std::file!(), std::line!() as usize, format!("Failed to download song {sid}: {e}")));
|
||||
cache.song_cache.insert(sid.clone(), DlStatus::Error(std::file!(), std::line!() as usize, format!("Failed to download song {sid}: {e}")));
|
||||
done_jobs.push(sid.clone());
|
||||
}
|
||||
}
|
||||
for sid in done_jobs {
|
||||
dlc.jobs.remove(&sid);
|
||||
}
|
||||
{
|
||||
let mut done_jobs = Vec::new();
|
||||
let mut dlc = he!(tx, downloader::icon::IconCacheDl::get());
|
||||
for (sid, status) in &dlc.jobs {
|
||||
if let DlStatus::Done(path) = status {
|
||||
let mut cache = he!(tx, CACHE.lock());
|
||||
cache.icon_cache.insert(sid.clone(), DlStatus::Done(path.clone()));
|
||||
done_jobs.push(sid.clone());
|
||||
}
|
||||
}
|
||||
for sid in done_jobs {
|
||||
dlc.jobs.remove(&sid);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
let mut cache = he!(tx, Cache::get());
|
||||
{
|
||||
let mut dlc = he!(tx, downloader::song::SongCacheDl::get());
|
||||
if !dlc.is_job_list_full() {
|
||||
if let Some((sid, song)) = cache.song_queue.pop() {
|
||||
he!(tx, dlc.download(sid, song));
|
||||
}
|
||||
}
|
||||
}
|
||||
{
|
||||
let mut icnc = he!(tx, downloader::icon::IconCacheDl::get());
|
||||
if !icnc.is_job_list_full() {
|
||||
if let Some((sid, song)) = cache.icon_queue.pop() {
|
||||
log::debug!("Downloading {sid:?}");
|
||||
he!(tx, icnc.download(sid, song));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -1,24 +0,0 @@
|
|||
[package]
|
||||
name = "xmpd-cliargs"
|
||||
edition.workspace = true
|
||||
readme="README.md"
|
||||
version.workspace = true
|
||||
authors.workspace = true
|
||||
repository.workspace = true
|
||||
license.workspace = true
|
||||
autobins = false
|
||||
autotests = false
|
||||
autoexamples = false
|
||||
|
||||
[features]
|
||||
default = []
|
||||
|
||||
[lib]
|
||||
crate-type = ["rlib"]
|
||||
bench = false
|
||||
|
||||
[dependencies]
|
||||
camino.workspace = true
|
||||
clap.workspace = true
|
||||
dirs.workspace = true
|
||||
lazy_static.workspace = true
|
||||
|
|
@ -1,37 +0,0 @@
|
|||
use std::{path::PathBuf, str::FromStr, sync::Arc};
|
||||
|
||||
use camino::Utf8PathBuf;
|
||||
use clap::Parser;
|
||||
|
||||
lazy_static::lazy_static!(
|
||||
pub static ref CLIARGS: Arc<CliArgs> = Arc::new(CliArgs::parse());
|
||||
);
|
||||
|
||||
#[derive(Debug, clap::Parser)]
|
||||
pub struct CliArgs {
|
||||
/// Manifest path
|
||||
#[arg(long, short)]
|
||||
manifest: Option<camino::Utf8PathBuf>,
|
||||
/// settings file path
|
||||
#[arg(long, short, default_value="./settings.toml")]
|
||||
settings: camino::Utf8PathBuf,
|
||||
/// Cache dir path
|
||||
#[arg(long, short)]
|
||||
cache: Option<camino::Utf8PathBuf>,
|
||||
/// Debug mode
|
||||
#[arg(long, short)]
|
||||
pub debug: bool,
|
||||
}
|
||||
|
||||
impl CliArgs {
|
||||
pub fn manifest_path(&self) -> Option<Utf8PathBuf> {
|
||||
self.manifest.clone()
|
||||
}
|
||||
pub fn settings_path(&self) -> Utf8PathBuf {
|
||||
self.settings.clone()
|
||||
}
|
||||
pub fn cache_path(&self) -> Option<Utf8PathBuf> {
|
||||
self.cache.clone()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1,36 +0,0 @@
|
|||
[package]
|
||||
name = "xmpd-core"
|
||||
edition.workspace = true
|
||||
readme="README.md"
|
||||
version.workspace = true
|
||||
authors.workspace = true
|
||||
repository.workspace = true
|
||||
license.workspace = true
|
||||
autobins = false
|
||||
autotests = false
|
||||
autoexamples = false
|
||||
|
||||
[features]
|
||||
default=["cli", "gui"]
|
||||
cli=[]
|
||||
gui=[]
|
||||
|
||||
|
||||
[[bin]]
|
||||
name="xmpd"
|
||||
path="src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
xmpd-cliargs.path="../xmpd-cliargs"
|
||||
xmpd-gui.path="../xmpd-gui"
|
||||
xmpd-manifest.path="../xmpd-manifest"
|
||||
xmpd-settings.path = "../xmpd-settings"
|
||||
xmpd-update.path = "../xmpd-update"
|
||||
clap.workspace=true
|
||||
camino.workspace = true
|
||||
anyhow.workspace = true
|
||||
log.workspace = true
|
||||
env_logger.workspace = true
|
||||
|
||||
[build-dependencies]
|
||||
winresource.workspace = true
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
use winresource::WindowsResource;
|
||||
|
||||
fn main() -> std::io::Result<()> {
|
||||
if std::env::var_os("CARGO_CFG_WINDOWS").is_some() {
|
||||
WindowsResource::new()
|
||||
// This path can be absolute, or relative to your crate root.
|
||||
.set_icon("../assets/icon.ico")
|
||||
.compile()?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
|
@ -1,16 +0,0 @@
|
|||
use log::LevelFilter;
|
||||
use xmpd_cliargs::CliArgs;
|
||||
|
||||
|
||||
pub fn init(cliargs: &CliArgs) {
|
||||
let level = if cliargs.debug { LevelFilter::Debug } else { LevelFilter::Info };
|
||||
env_logger::builder()
|
||||
.format_timestamp(None)
|
||||
.filter(Some("xmpd"), level)
|
||||
.filter(Some("xmpd_cli"), level)
|
||||
.filter(Some("xmpd_gui"), level)
|
||||
.filter(Some("xmpd_manifest"), level)
|
||||
.filter(Some("xmpd_config"), level)
|
||||
.filter(Some("xmpd_dl"), level)
|
||||
.init();
|
||||
}
|
||||
|
|
@ -1,25 +0,0 @@
|
|||
use std::borrow::BorrowMut;
|
||||
|
||||
use clap::Parser;
|
||||
|
||||
mod logger;
|
||||
|
||||
type Result<T> = anyhow::Result<T>;
|
||||
|
||||
fn main() -> Result<()> {
|
||||
// NOTE: Parses on first load
|
||||
let cliargs = &xmpd_cliargs::CLIARGS;
|
||||
logger::init(&cliargs);
|
||||
log::info!("Initialising settings");
|
||||
{
|
||||
xmpd_settings::Settings::get()?.load(Some(cliargs.settings_path().into_std_path_buf()))?;
|
||||
xmpd_settings::Settings::get()?.load_cli_args(cliargs);
|
||||
}
|
||||
|
||||
log::info!("Starting updater");
|
||||
xmpd_update::Update::new().update_xmpd_if_needed()?;
|
||||
|
||||
log::info!("Starting gui");
|
||||
xmpd_gui::start()?;
|
||||
Ok(())
|
||||
}
|
||||
|
|
@ -1,37 +0,0 @@
|
|||
[package]
|
||||
name = "xmpd-gui"
|
||||
edition.workspace = true
|
||||
readme="README.md"
|
||||
authors.workspace = true
|
||||
version.workspace = true
|
||||
repository.workspace = true
|
||||
license.workspace = true
|
||||
autobins = false
|
||||
autotests = false
|
||||
autoexamples = false
|
||||
|
||||
[features]
|
||||
default = []
|
||||
|
||||
[lib]
|
||||
crate-type = ["rlib"]
|
||||
bench = false
|
||||
|
||||
[dependencies]
|
||||
xmpd-manifest.path = "../xmpd-manifest"
|
||||
xmpd-settings.path = "../xmpd-settings"
|
||||
xmpd-cliargs.path = "../xmpd-cliargs"
|
||||
xmpd-cache.path = "../xmpd-cache"
|
||||
xmpd-player.path = "../xmpd-player"
|
||||
egui.workspace = true
|
||||
eframe.workspace = true
|
||||
anyhow.workspace = true
|
||||
lazy_static.workspace = true
|
||||
log.workspace = true
|
||||
egui_extras.workspace = true
|
||||
uuid.workspace = true
|
||||
camino.workspace = true
|
||||
rfd.workspace = true
|
||||
dirs.workspace = true
|
||||
downcast-rs.workspace = true
|
||||
url.workspace = true
|
||||
|
|
@ -1,41 +0,0 @@
|
|||
use uuid::Uuid;
|
||||
use crate::{components::{CompGetter, CompUi}, windows::WindowId};
|
||||
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub struct Header {
|
||||
pub search_text: String,
|
||||
}
|
||||
|
||||
component_register!(Header);
|
||||
|
||||
impl CompUi for Header {
|
||||
fn draw(ui: &mut egui::Ui, state: &mut crate::GuiState) -> crate::Result<()> {
|
||||
let theme = xmpd_settings::Settings::get()?.theme.clone();
|
||||
|
||||
ui.vertical(|ui| {
|
||||
ui.horizontal(|ui| {
|
||||
let search_icon = egui::Image::new(crate::data::SEARCH_ICON)
|
||||
.fit_to_exact_size(egui::Vec2::new(16.0, 16.0))
|
||||
.tint(theme.accent_color);
|
||||
ui.add(search_icon);
|
||||
{
|
||||
ui.text_edit_singleline(&mut handle_error_ui!(Header::get()).search_text);
|
||||
}
|
||||
});
|
||||
//ui.with_layout(egui::Layout::top_down(egui::Align::), add_contents)
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Min), |ui| {
|
||||
let add_song = ui.add(
|
||||
egui::Image::new(crate::data::PLUS_ICON)
|
||||
.tint(theme.accent_color)
|
||||
.sense(egui::Sense::click())
|
||||
.fit_to_exact_size(egui::Vec2::new(16.0, 16.0))
|
||||
);
|
||||
if add_song.clicked() {
|
||||
state.windows.toggle(&WindowId::NewPlaylist, true);
|
||||
}
|
||||
});
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
|
@ -1,105 +0,0 @@
|
|||
use egui::{CursorIcon, RichText, Sense, TextBuffer};
|
||||
use xmpd_manifest::store::{BaseStore, StoreExtras};
|
||||
use crate::utils::SearchType;
|
||||
|
||||
use super::{CompGetter, CompUi};
|
||||
|
||||
pub mod header;
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub struct LeftNav {
|
||||
pub selected_playlist_id: Option<uuid::Uuid>,
|
||||
}
|
||||
|
||||
component_register!(LeftNav);
|
||||
|
||||
impl CompUi for LeftNav {
|
||||
fn draw(ui: &mut egui::Ui, state: &mut crate::GuiState) -> crate::Result<()> {
|
||||
let w = ui.available_width();
|
||||
egui::ScrollArea::vertical()
|
||||
.id_source("left_nav")
|
||||
.drag_to_scroll(false)
|
||||
.show(ui, |ui| {
|
||||
ui.vertical(|ui| {
|
||||
let len = state.manifest.store().get_songs().len();
|
||||
add_playlist_tab(ui, &None, "All Songs", None, len, w);
|
||||
let playlists = state.manifest.store().get_playlists_sorted();
|
||||
let search_text = handle_error_ui!(header::Header::get()).search_text.clone();
|
||||
let (qtyp, qtxt) = crate::utils::SearchType::from_str(&search_text);
|
||||
for (pid, playlist) in playlists.iter() {
|
||||
match qtyp {
|
||||
_ if qtxt.is_empty() => (),
|
||||
SearchType::Normal if playlist.name().to_lowercase().contains(&qtxt) => (),
|
||||
SearchType::Author if playlist.author().to_lowercase().contains(&qtxt) => (),
|
||||
_ => continue
|
||||
}
|
||||
add_playlist_tab(ui,
|
||||
&Some(**pid),
|
||||
playlist.name(),
|
||||
Some(playlist.author()),
|
||||
playlist.songs().len(),
|
||||
w
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn add_playlist_tab(ui: &mut egui::Ui, pid: &Option<uuid::Uuid>, title: &str, author: Option<&str>, song_count: usize, width: f32) {
|
||||
if pid.is_some() {
|
||||
ui.separator();
|
||||
}
|
||||
let theme = &handle_error_ui!(xmpd_settings::Settings::get()).theme;
|
||||
let wdg_rect = ui.horizontal(|ui| {
|
||||
ui.set_width(width);
|
||||
ui.add_space(5.0);
|
||||
ui.add(
|
||||
egui::Image::new(crate::data::NOTE_ICON)
|
||||
.tint(theme.accent_color)
|
||||
.sense(Sense::click())
|
||||
.fit_to_exact_size(egui::Vec2::new(32.0, 32.0))
|
||||
);
|
||||
ui.vertical(|ui| {
|
||||
{
|
||||
if handle_error_ui!(LeftNav::get()).selected_playlist_id == *pid {
|
||||
ui.label(
|
||||
RichText::new(title)
|
||||
.size(10.0)
|
||||
.color(theme.accent_color)
|
||||
);
|
||||
} else {
|
||||
ui.label(
|
||||
RichText::new(title)
|
||||
.color(theme.text_color)
|
||||
.size(10.0)
|
||||
);
|
||||
}
|
||||
}
|
||||
if let Some(author) = author {
|
||||
ui.monospace(
|
||||
RichText::new(format!("By {author}"))
|
||||
.color(theme.dim_text_color)
|
||||
.size(8.0)
|
||||
);
|
||||
}
|
||||
ui.monospace(
|
||||
RichText::new(format!("{song_count} songs"))
|
||||
.color(theme.dim_text_color)
|
||||
.size(8.0)
|
||||
);
|
||||
});
|
||||
}).response.rect;
|
||||
|
||||
let blob = ui.interact(wdg_rect, format!("left_nav_playlist_{pid:?}").into(), egui::Sense::click());
|
||||
if blob.clicked() {
|
||||
handle_error_ui!(LeftNav::get()).selected_playlist_id = pid.clone();
|
||||
}
|
||||
if blob.hovered() {
|
||||
ui.output_mut(|o| o.cursor_icon = CursorIcon::PointingHand);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -1,17 +0,0 @@
|
|||
use std::sync::MutexGuard;
|
||||
|
||||
use crate::GuiState;
|
||||
|
||||
pub mod left_nav;
|
||||
pub mod song_list;
|
||||
pub mod top_nav;
|
||||
pub mod player;
|
||||
pub mod toast;
|
||||
|
||||
pub trait CompUi {
|
||||
fn draw(ui: &mut egui::Ui, state: &mut GuiState) -> crate::Result<()>;
|
||||
}
|
||||
|
||||
pub trait CompGetter {
|
||||
fn get() -> crate::Result<MutexGuard<'static, Self>>;
|
||||
}
|
||||
|
|
@ -1,159 +0,0 @@
|
|||
use egui::{RichText, Sense, Stroke, Vec2};
|
||||
use xmpd_manifest::store::BaseStore;
|
||||
|
||||
use super::{song_list::SongList, CompGetter, CompUi};
|
||||
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct Player {
|
||||
slider_progress: usize,
|
||||
old_slider_progress: usize,
|
||||
volume_slider: f64,
|
||||
}
|
||||
|
||||
impl Default for Player {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
volume_slider: 1.0,
|
||||
old_slider_progress: 0,
|
||||
slider_progress: 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
component_register!(Player);
|
||||
|
||||
|
||||
impl CompUi for Player {
|
||||
fn draw(ui: &mut egui::Ui, state: &mut crate::GuiState) -> crate::Result<()> {
|
||||
let theme = xmpd_settings::Settings::get()?.theme.clone();
|
||||
let full_avail = ui.available_size();
|
||||
ui.horizontal_centered(|ui| {
|
||||
ui.add_space(10.0);
|
||||
let icon = egui::Image::new(crate::data::NOTE_ICON)
|
||||
.tint(theme.accent_color)
|
||||
.sense(Sense::click())
|
||||
.fit_to_exact_size(Vec2::new(32.0, 32.0));
|
||||
ui.add(icon);
|
||||
ui.vertical(|ui| {
|
||||
|
||||
ui.add_space(5.0);
|
||||
let sid = &handle_error_ui!(SongList::get()).selected_sid;
|
||||
if let Some(song) = state.manifest.store().get_song(sid) {
|
||||
let mut name = song.name().to_string();
|
||||
if name.len() > 16 {
|
||||
name = (&name)[..16].to_string();
|
||||
name.push_str("...");
|
||||
}
|
||||
ui.label(
|
||||
RichText::new(name)
|
||||
.size(12.0)
|
||||
);
|
||||
ui.label(
|
||||
RichText::new(song.author())
|
||||
.size(8.0)
|
||||
.monospace()
|
||||
);
|
||||
}
|
||||
});
|
||||
ui.vertical_centered_justified(|ui| {
|
||||
let avail = ui.available_size();
|
||||
let song_info_w = full_avail.x - avail.x;
|
||||
ui.add_space(3.0);
|
||||
ui.horizontal(|ui| {
|
||||
|
||||
{
|
||||
let slider_width = full_avail.x * 0.60;
|
||||
ui.add_space((((full_avail.x / 2.0) - song_info_w) - slider_width / 2.0).clamp(0.0, f32::MAX));
|
||||
ui.style_mut().spacing.slider_width = avail.x * 0.75;
|
||||
let s = Stroke {
|
||||
color: theme.accent_color,
|
||||
width: 2.0
|
||||
};
|
||||
ui.style_mut().visuals.widgets.inactive.fg_stroke = s;
|
||||
ui.style_mut().visuals.widgets.active.fg_stroke = s;
|
||||
ui.style_mut().visuals.widgets.hovered.fg_stroke = s;
|
||||
|
||||
let mut slf = handle_error_ui!(Player::get());
|
||||
ui.add(
|
||||
egui::Slider::new(&mut slf.slider_progress, 0..=100)
|
||||
.show_value(false)
|
||||
);
|
||||
if slf.slider_progress == slf.old_slider_progress {
|
||||
slf.slider_progress = (state.player.get_played_f() * 100.0) as usize;
|
||||
slf.old_slider_progress = slf.slider_progress;
|
||||
} else {
|
||||
handle_error_ui!(state.player.seek_to_f(slf.slider_progress as f64 / 100.0 ));
|
||||
slf.old_slider_progress = slf.slider_progress;
|
||||
}
|
||||
let secs_left = state.player.get_ms_left() as f64 / 1000.0;
|
||||
let h = (secs_left/60.0/60.0).floor();
|
||||
let m = ((secs_left - h * 60.0)/60.0).floor();
|
||||
let s = (secs_left - m * 60.0).floor();
|
||||
|
||||
ui.label(format!("{h:02}:{m:02}:{s:02}"));
|
||||
}
|
||||
});
|
||||
ui.horizontal(|ui| {
|
||||
let icon_size = 16.0;
|
||||
ui.add_space(((full_avail.x / 2.0) - song_info_w) - icon_size * 1.5 - ui.spacing().item_spacing.x);
|
||||
let pp = if state.player.is_paused() {
|
||||
crate::data::PLAY_ICON
|
||||
} else {
|
||||
crate::data::PAUSE_ICON
|
||||
};
|
||||
|
||||
let prev = egui::Image::new(crate::data::PREV_ICON)
|
||||
.tint(theme.accent_color)
|
||||
.sense(Sense::click())
|
||||
.max_size(Vec2::new(icon_size, icon_size));
|
||||
let pp = egui::Image::new(pp)
|
||||
.tint(theme.accent_color)
|
||||
.sense(Sense::click())
|
||||
.max_size(Vec2::new(icon_size, icon_size));
|
||||
let next = egui::Image::new(crate::data::NEXT_ICON)
|
||||
.tint(theme.accent_color)
|
||||
.sense(Sense::click())
|
||||
.max_size(Vec2::new(icon_size, icon_size));
|
||||
if ui.add(prev).clicked() {
|
||||
handle_error_ui!(handle_error_ui!(SongList::get()).play_prev(state));
|
||||
}
|
||||
if ui.add(pp).clicked() {
|
||||
if state.player.is_paused() {
|
||||
state.player.play();
|
||||
} else {
|
||||
state.player.pause();
|
||||
}
|
||||
}
|
||||
if ui.add(next).clicked() || state.player.just_stopped() {
|
||||
handle_error_ui!(handle_error_ui!(SongList::get()).play_next(state));
|
||||
}
|
||||
|
||||
|
||||
|
||||
ui.add_space(15.0);
|
||||
ui.style_mut().spacing.slider_width = avail.x * 0.15;
|
||||
let s = Stroke {
|
||||
color: theme.accent_color,
|
||||
width: 1.0
|
||||
};
|
||||
ui.style_mut().visuals.widgets.inactive.fg_stroke = s;
|
||||
ui.style_mut().visuals.widgets.active.fg_stroke = s;
|
||||
ui.style_mut().visuals.widgets.hovered.fg_stroke = s;
|
||||
|
||||
let mut slf = handle_error_ui!(Player::get());
|
||||
let slider =ui.add(
|
||||
egui::Slider::new(&mut slf.volume_slider, 0.0..=1.0)
|
||||
.show_value(false)
|
||||
);
|
||||
|
||||
if slider.changed() {
|
||||
state.player.set_volume(slf.volume_slider);
|
||||
}
|
||||
});
|
||||
ui.add_space(3.0);
|
||||
});
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
|
@ -1,94 +0,0 @@
|
|||
use uuid::Uuid;
|
||||
use xmpd_cache::DlStatus;
|
||||
use xmpd_manifest::{song::Song, store::BaseStore};
|
||||
|
||||
use crate::{components::{left_nav::LeftNav, toast::ToastType, CompGetter, CompUi}, windows::WindowId};
|
||||
|
||||
use super::SongList;
|
||||
|
||||
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub struct Header {
|
||||
pub search_text: String,
|
||||
}
|
||||
|
||||
component_register!(Header);
|
||||
|
||||
impl CompUi for Header {
|
||||
fn draw(ui: &mut egui::Ui, state: &mut crate::GuiState) -> crate::Result<()> {
|
||||
let theme = xmpd_settings::Settings::get()?.theme.clone();
|
||||
let pid = {LeftNav::get()?.selected_playlist_id.clone()};
|
||||
|
||||
ui.horizontal(|ui| {
|
||||
let search_icon = egui::Image::new(crate::data::SEARCH_ICON)
|
||||
.fit_to_exact_size(egui::Vec2::new(16.0, 16.0))
|
||||
.tint(theme.accent_color);
|
||||
ui.add(search_icon);
|
||||
{
|
||||
ui.text_edit_singleline(&mut handle_error_ui!(Header::get()).search_text);
|
||||
}
|
||||
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::RIGHT), |ui| {
|
||||
let download_all = ui.add(
|
||||
egui::Image::new(crate::data::DL_ICON)
|
||||
.tint(theme.accent_color)
|
||||
.sense(egui::Sense::click())
|
||||
.fit_to_exact_size(egui::Vec2::new(16.0, 16.0))
|
||||
);
|
||||
let add_song = ui.add(
|
||||
egui::Image::new(crate::data::PLUS_ICON)
|
||||
.tint(theme.accent_color)
|
||||
.sense(egui::Sense::click())
|
||||
.fit_to_exact_size(egui::Vec2::new(16.0, 16.0))
|
||||
);
|
||||
if download_all.clicked() {
|
||||
let songs: Vec<_>;
|
||||
match pid {
|
||||
Some(pid) => {
|
||||
songs = state.manifest.store().get_playlist(&pid).unwrap().songs().to_vec();
|
||||
}
|
||||
None => {
|
||||
songs = state.manifest.store().get_songs().keys().cloned().collect();
|
||||
}
|
||||
|
||||
}
|
||||
for sid in handle_error_ui!(Self::get_songs_to_download(&songs)) {
|
||||
if let Some(song) = state.manifest.store().get_song(&sid) {
|
||||
handle_error_ui!(xmpd_cache::Cache::get()).download_song_to_cache(sid.clone(), song.clone())
|
||||
}
|
||||
}
|
||||
let mut toast = handle_error_ui!(crate::components::toast::Toast::get());
|
||||
toast.show_toast(
|
||||
"Downloading Songs",
|
||||
&format!("Started downloading {} songs", songs.len()),
|
||||
ToastType::Info
|
||||
);
|
||||
}
|
||||
|
||||
if add_song.clicked() {
|
||||
state.windows.toggle(&WindowId::AddSongToPl, true);
|
||||
}
|
||||
});
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Header {
|
||||
|
||||
fn get_songs_to_download(songs: &Vec<uuid::Uuid>) -> crate::Result<Vec<uuid::Uuid>> {
|
||||
let mut songs2 = Vec::new();
|
||||
|
||||
for sid in songs {
|
||||
if let None = xmpd_cache::Cache::get()?.get_cached_song_status(&sid) {
|
||||
songs2.push(sid.clone());
|
||||
}
|
||||
}
|
||||
Ok(songs2)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
@ -1,301 +0,0 @@
|
|||
use anyhow::anyhow;
|
||||
use egui::{Color32, CursorIcon, ImageSource, RichText, Sense, Vec2};
|
||||
use xmpd_cache::DlStatus;
|
||||
use xmpd_manifest::{query, song::Song, store::{BaseStore, StoreExtras}};
|
||||
use crate::{components::toast::{Toast, ToastType}, utils::SearchType, windows::WindowId};
|
||||
use std::any::Any;
|
||||
use super::{CompGetter, CompUi};
|
||||
use downcast_rs::Downcast;
|
||||
|
||||
pub mod header;
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub struct SongList {
|
||||
pub selected_sid: uuid::Uuid,
|
||||
playable_songs: Vec<uuid::Uuid>,
|
||||
}
|
||||
|
||||
component_register!(SongList);
|
||||
|
||||
impl CompUi for SongList {
|
||||
fn draw(ui: &mut egui::Ui, state: &mut crate::GuiState) -> crate::Result<()> {
|
||||
let songs = Self::get_and_sort_songs(state)?;
|
||||
let disp_songs = Self::get_songs_to_display(&songs)?;
|
||||
{
|
||||
let mut sl = SongList::get()?;
|
||||
sl.playable_songs = Self::get_playable_songs(&songs)?;
|
||||
if let Some((sid, _)) = songs.first() {
|
||||
if sl.selected_sid == Default::default() {
|
||||
sl.selected_sid = (*sid).clone();
|
||||
}
|
||||
}
|
||||
}
|
||||
egui::ScrollArea::vertical()
|
||||
.id_source("song_list")
|
||||
.drag_to_scroll(false)
|
||||
.show(ui, |ui| {
|
||||
ui.vertical(|ui| {
|
||||
ui.add_space(3.0);
|
||||
for sid in disp_songs {
|
||||
handle_error_ui!(Self::display_song_tab(ui, state, &sid));
|
||||
}
|
||||
});
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl SongList {
|
||||
fn get_and_sort_songs(state: &mut crate::GuiState) -> crate::Result<Vec<(&uuid::Uuid, &Song)>> {
|
||||
let pid = super::left_nav::LeftNav::get()?.selected_playlist_id.clone();
|
||||
match pid {
|
||||
None => {
|
||||
Ok(state.manifest.store().get_songs_sorted())
|
||||
}
|
||||
Some(pid) => {
|
||||
Ok(state.manifest.store().get_playlist_songs_sorted(pid).expect("Invalid pid"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn get_songs_to_display<'a>(songs: &'a [(&uuid::Uuid, &Song)]) -> crate::Result<Vec<uuid::Uuid>>{
|
||||
let mut to_display = Vec::new();
|
||||
let query = {header::Header::get()?.search_text.clone()};
|
||||
let (query_type, query_text) = crate::utils::SearchType::from_str(&query);
|
||||
for (sid, song) in songs {
|
||||
let should_display = match &query_type {
|
||||
SearchType::Normal |
|
||||
SearchType::Author |
|
||||
SearchType::Source if query_text.is_empty() => true,
|
||||
|
||||
SearchType::Source => {
|
||||
song.source_type().to_string()
|
||||
.to_lowercase()
|
||||
.contains(&query_text)
|
||||
},
|
||||
SearchType::Author => {
|
||||
song.author()
|
||||
.to_lowercase()
|
||||
.contains(&query_text)
|
||||
},
|
||||
SearchType::Normal => {
|
||||
song.name()
|
||||
.to_lowercase()
|
||||
.contains(&query_text)
|
||||
},
|
||||
};
|
||||
|
||||
if should_display {
|
||||
to_display.push((*sid).clone());
|
||||
}
|
||||
}
|
||||
Ok(to_display)
|
||||
}
|
||||
|
||||
fn display_song_tab(ui: &mut egui::Ui, state: &mut crate::GuiState, sid: &uuid::Uuid) -> crate::Result<()> {
|
||||
let mut clicked = false;
|
||||
ui.horizontal(|ui| {
|
||||
let song = handle_option!("(internal)", state.manifest.store().get_song(sid)).clone();
|
||||
let theme = handle_error_ui!(xmpd_settings::Settings::get()).theme.clone();
|
||||
// let icon_status = handle_error_ui!(xmpd_cache::Cache::get()).get_cached_icon_status(&sid).clone();
|
||||
let img = ui.add(
|
||||
//if let Some(DlStatus::Done(Some(p))) = icon_status {
|
||||
// let uri: Cow<str> = Cow::Owned(p.to_string_lossy().to_string());
|
||||
// let bytes = handle_error_ui!(std::fs::read(p));
|
||||
// ui.ctx().include_bytes(uri.clone(), bytes);
|
||||
// egui::Image::new(ImageSource::Uri(uri))
|
||||
// .sense(Sense::click())
|
||||
// .fit_to_exact_size(Vec2::new(32.0, 32.0))
|
||||
// } else {
|
||||
egui::Image::new(crate::data::NOTE_ICON)
|
||||
.tint(theme.accent_color)
|
||||
.sense(Sense::click())
|
||||
.fit_to_exact_size(Vec2::new(32.0, 32.0))
|
||||
//}
|
||||
);
|
||||
let status = {
|
||||
handle_error_ui!(xmpd_cache::Cache::get()).get_cached_song_status(&sid).clone()
|
||||
};
|
||||
if img.clicked() {
|
||||
clicked = true;
|
||||
}
|
||||
if img.hovered() {
|
||||
if matches!(status, Some(DlStatus::Done(_))) {
|
||||
ui.output_mut(|o| o.cursor_icon = CursorIcon::PointingHand);
|
||||
} else {
|
||||
ui.output_mut(|o| o.cursor_icon = CursorIcon::Default);
|
||||
}
|
||||
}
|
||||
// img.context_menu(|ui| handle_error_ui!(Self::show_context_menu(ui, sid, song)));
|
||||
|
||||
ui.vertical(|ui| {
|
||||
let slf = handle_error_ui!(SongList::get());
|
||||
let label = if slf.selected_sid == *sid {
|
||||
RichText::new(song.name())
|
||||
.color(theme.accent_color)
|
||||
} else if matches!(status, Some(DlStatus::Done(_))) {
|
||||
RichText::new(song.name())
|
||||
.color(theme.text_color)
|
||||
} else {
|
||||
RichText::new(song.name())
|
||||
.color(theme.dim_text_color)
|
||||
};
|
||||
let label = ui.label(label);
|
||||
|
||||
if label.clicked() {
|
||||
clicked = true;
|
||||
}
|
||||
if label.hovered() {
|
||||
if matches!(status, Some(DlStatus::Done(_))) {
|
||||
ui.output_mut(|o| o.cursor_icon = CursorIcon::PointingHand);
|
||||
} else {
|
||||
ui.output_mut(|o| o.cursor_icon = CursorIcon::Default);
|
||||
}
|
||||
}
|
||||
|
||||
label.context_menu(|ui| handle_error_ui!(Self::show_context_menu(state, ui, sid, &song)));
|
||||
ui.monospace(
|
||||
RichText::new(format!("By {}", song.author()))
|
||||
.color(theme.dim_text_color)
|
||||
.size(10.0)
|
||||
);
|
||||
|
||||
});
|
||||
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::RIGHT), |ui| {
|
||||
ui.add_space(3.0);
|
||||
|
||||
match status {
|
||||
Some(DlStatus::Done(_)) => {
|
||||
let img = egui::Image::new(crate::data::CHECK_ICON)
|
||||
.tint(Color32::LIGHT_GREEN)
|
||||
.sense(Sense::hover())
|
||||
.fit_to_exact_size(Vec2::new(16.0, 16.0));
|
||||
|
||||
ui.add(img).on_hover_ui(|ui| {
|
||||
ui.label(format!("Id: {sid}"));
|
||||
});
|
||||
}
|
||||
Some(DlStatus::Downloading) => {
|
||||
let spinner = egui::Spinner::new()
|
||||
.color(theme.accent_color)
|
||||
.size(16.0);
|
||||
ui.add(spinner);
|
||||
}
|
||||
Some(DlStatus::Error(_, _, e)) => {
|
||||
let img = egui::Image::new(crate::data::WARN_ICON)
|
||||
.tint(Color32::LIGHT_YELLOW)
|
||||
.sense(Sense::hover())
|
||||
.fit_to_exact_size(Vec2::new(16.0, 16.0));
|
||||
|
||||
ui.add(img).on_hover_ui(|ui| {
|
||||
ui.label(e);
|
||||
});
|
||||
}
|
||||
None => {
|
||||
let img = egui::Image::new(crate::data::DL_ICON)
|
||||
.tint(theme.accent_color)
|
||||
.sense(Sense::click())
|
||||
.fit_to_exact_size(Vec2::new(16.0, 16.0));
|
||||
|
||||
if ui.add(img).clicked() {
|
||||
handle_error_ui!(xmpd_cache::Cache::get()).download_song_to_cache(sid.clone(), song.clone());
|
||||
let mut toast = handle_error_ui!(crate::components::toast::Toast::get());
|
||||
toast.show_toast(
|
||||
"Downloading Song",
|
||||
&format!("Started downloading {} by {}", song.name(), song.author()),
|
||||
super::toast::ToastType::Info
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
ui.separator();
|
||||
if clicked {
|
||||
let mut sl = SongList::get()?;
|
||||
sl.play_song(sid.clone(), state)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
pub fn play_song(&mut self, sid: uuid::Uuid, state: &mut crate::GuiState) -> crate::Result<()> {
|
||||
if self.playable_songs.contains(&sid) {
|
||||
self.selected_sid = sid.clone();
|
||||
let path = state.manifest.get_song_as_path(sid)?;
|
||||
state.player.play_song(&path)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
pub fn play_prev(&mut self, state: &mut crate::GuiState) -> crate::Result<()> {
|
||||
let Some(mut prev) = self.playable_songs.last().cloned() else {
|
||||
anyhow::bail!("Trying to play a song in an empty playlist (impossible)")
|
||||
};
|
||||
for sid in self.playable_songs.clone() {
|
||||
if sid == self.selected_sid {
|
||||
self.play_song(prev, state)?;
|
||||
}
|
||||
prev = sid;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
pub fn play_next(&mut self, state: &mut crate::GuiState) -> crate::Result<()> {
|
||||
let Some(mut next) = self.playable_songs.first().cloned() else {
|
||||
anyhow::bail!("Trying to play a song in an empty playlist (impossible)")
|
||||
};
|
||||
let mut found = false;
|
||||
for sid in self.playable_songs.clone() {
|
||||
if sid == self.selected_sid {
|
||||
found = true;
|
||||
} else if found {
|
||||
next = sid;
|
||||
break;
|
||||
}
|
||||
}
|
||||
self.play_song(next, state)?;
|
||||
Ok(())
|
||||
}
|
||||
fn get_playable_songs(songs: &[(&uuid::Uuid, &Song)]) -> crate::Result<Vec<uuid::Uuid>> {
|
||||
let mut playable_songs = Vec::new();
|
||||
|
||||
for (sid, _) in songs {
|
||||
if let Some(DlStatus::Done(_)) = xmpd_cache::Cache::get()?.get_cached_song_status(&sid) {
|
||||
playable_songs.push((*sid).clone());
|
||||
}
|
||||
}
|
||||
Ok(playable_songs)
|
||||
}
|
||||
fn show_context_menu(state: &mut crate::GuiState, ui: &mut egui::Ui, sid: &uuid::Uuid, song: &Song) -> crate::Result<()> {
|
||||
if ui.button("Edit").clicked() {
|
||||
// TODO: Implement song editing
|
||||
Toast::get().unwrap().show_toast("Not Implemented", "Song editing is not implemented", ToastType::Error);
|
||||
//state.windows.toggle(&crate::windows::WindowId::NewSong, status);
|
||||
ui.close_menu();
|
||||
}
|
||||
if ui.button("Add to playlist").clicked() {
|
||||
// TODO: Implement song editing
|
||||
Toast::get().unwrap().show_toast("Not Implemented", "Adding songs to another playlist is not implemented, go to that playlist and press add song", ToastType::Error);
|
||||
//state.windows.toggle(&WindowId::AddSongToPl, true);
|
||||
//let mut windows = crate::windows::WINDOWS.lock().map_err(|e| anyhow!("{e}"))?;
|
||||
//let mut w = windows.get_mut(&WindowId::AddSongToPl);
|
||||
//let w = w.as_any_mut();
|
||||
|
||||
ui.close_menu();
|
||||
}
|
||||
if ui.button("Songs by artist").clicked() {
|
||||
crate::components::song_list::header::Header::get()?.search_text = format!("author:{}", song.author());
|
||||
ui.close_menu();
|
||||
}
|
||||
if ui.button(RichText::new("Remove from playlist").color(Color32::RED)).clicked() {
|
||||
Toast::get().unwrap().show_toast("Not Implemented", "Removing songs from playlists is not implemented", ToastType::Error);
|
||||
ui.close_menu();
|
||||
}
|
||||
if ui.button(RichText::new("Remove song globally").color(Color32::RED)).clicked() {
|
||||
Toast::get().unwrap().show_toast("Not Implemented", "Removing songs globally is not implemented", ToastType::Error);
|
||||
ui.close_menu();
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
|
@ -1,114 +0,0 @@
|
|||
|
||||
use std::{collections::VecDeque, time::SystemTime};
|
||||
|
||||
use egui::{epaint::Shadow, load::TexturePoll, Align2, Color32, Frame, Image, ImageSource, Margin, Pos2, Rect, RichText, Rounding, Stroke, Style, TextureFilter, TextureOptions, TextureWrapMode, Vec2};
|
||||
|
||||
use super::{CompGetter, CompUi};
|
||||
|
||||
#[derive(Debug, Default, PartialEq, Clone, Copy)]
|
||||
pub enum ToastType {
|
||||
#[default]
|
||||
Info,
|
||||
Warn,
|
||||
Error,
|
||||
}
|
||||
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub struct Toast {
|
||||
queue: VecDeque<(String, String, ToastType, SystemTime)>
|
||||
}
|
||||
|
||||
component_register!(Toast);
|
||||
|
||||
impl CompUi for Toast {
|
||||
fn draw(ui: &mut egui::Ui, _: &mut crate::GuiState) -> crate::Result<()> {
|
||||
let screen_size = ui.ctx().screen_rect().size();
|
||||
let (w, h) = (300.0, 100.0);
|
||||
let theme = &xmpd_settings::Settings::get()?.theme;
|
||||
let mut toastw = Toast::get()?;
|
||||
let mut height_iter = 6.0;
|
||||
let mut to_remove = Vec::new();
|
||||
|
||||
for (i, (title, description, toast_type, shown_since)) in toastw.queue.iter().enumerate() {
|
||||
let area = egui::Area::new(egui::Id::new(format!("toast_{i}")))
|
||||
.fixed_pos(Pos2::new(screen_size.x - w, height_iter))
|
||||
.pivot(Align2::LEFT_TOP)
|
||||
.show(ui.ctx(), |ui| {
|
||||
ui.set_width(w);
|
||||
|
||||
let img;
|
||||
let color;
|
||||
match toast_type {
|
||||
ToastType::Info => {
|
||||
color = theme.accent_color;
|
||||
img = Image::new(crate::data::INFO_ICON)
|
||||
.fit_to_exact_size(Vec2::new(16.0, 16.0))
|
||||
.tint(color);
|
||||
}
|
||||
ToastType::Warn => {
|
||||
color = crate::data::C_WARN;
|
||||
img = Image::new(crate::data::WARN_ICON)
|
||||
.fit_to_exact_size(Vec2::new(16.0, 16.0))
|
||||
.texture_options(TextureOptions {
|
||||
magnification: TextureFilter::Linear,
|
||||
minification: TextureFilter::Linear,
|
||||
wrap_mode: TextureWrapMode::ClampToEdge,
|
||||
})
|
||||
.tint(color);
|
||||
}
|
||||
ToastType::Error => {
|
||||
color = Color32::LIGHT_RED;
|
||||
img = Image::new(crate::data::ERROR_ICON)
|
||||
.fit_to_exact_size(Vec2::new(16.0, 16.0))
|
||||
.tint(color);
|
||||
}
|
||||
}
|
||||
Frame::none()
|
||||
.stroke(Stroke::new(1.0, color))
|
||||
.fill(theme.primary_bg_color)
|
||||
.rounding(Rounding::same(3.0))
|
||||
.inner_margin(Margin::same(3.0))
|
||||
.show(ui, |ui| {
|
||||
ui.set_width(w-9.0);
|
||||
ui.style_mut().visuals.override_text_color = Some(theme.text_color);
|
||||
ui.horizontal(|ui| {
|
||||
|
||||
ui.add(img);
|
||||
ui.label(RichText::new(title));
|
||||
});
|
||||
ui.label(
|
||||
RichText::new(description)
|
||||
.size(10.0)
|
||||
);
|
||||
ui.shrink_height_to_current();
|
||||
// height_iter += ui.available_height();
|
||||
}
|
||||
)
|
||||
}
|
||||
);
|
||||
height_iter += area.response.rect.height() + 6.0;
|
||||
|
||||
// if shown for longer than 5 seconds remove it
|
||||
if SystemTime::now().duration_since(*shown_since)?.as_secs() > 5 {
|
||||
to_remove.push(i);
|
||||
}
|
||||
}
|
||||
|
||||
for idx in to_remove {
|
||||
toastw.queue.remove(idx);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
impl Toast {
|
||||
pub fn show_toast<S>(&mut self, title: S, description: S, toast_type: ToastType)
|
||||
where S: ToString
|
||||
{
|
||||
self.queue.push_front((title.to_string(), description.to_string(), toast_type, SystemTime::now()));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1,93 +0,0 @@
|
|||
use std::path::PathBuf;
|
||||
|
||||
use egui::{Layout, TextBuffer};
|
||||
use xmpd_manifest::store::{JsonStore, TomlStore};
|
||||
|
||||
use crate::windows::WindowId;
|
||||
|
||||
use super::{CompGetter, CompUi};
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub struct TopNav {
|
||||
// for dialog
|
||||
manifest_path: Option<(PathBuf, String)>
|
||||
}
|
||||
|
||||
component_register!(TopNav);
|
||||
|
||||
impl CompUi for TopNav {
|
||||
fn draw(ui: &mut egui::Ui, state: &mut crate::GuiState) -> crate::Result<()> {
|
||||
ui.add_space(3.0);
|
||||
ui.horizontal(|ui| {
|
||||
ui.add_space(3.0);
|
||||
egui::menu::bar(ui, |ui| {
|
||||
ui.menu_button("File", |ui| {
|
||||
if ui.button("Settings").clicked() {
|
||||
state.windows.toggle(&WindowId::Settings, true);
|
||||
ui.close_menu();
|
||||
}
|
||||
});
|
||||
ui.menu_button("Manifest", |ui| {
|
||||
if ui.button("Add New Song").clicked() {
|
||||
state.windows.toggle(&WindowId::NewSong, true);
|
||||
}
|
||||
if ui.button("Save").clicked() {
|
||||
handle_error_ui!(state.manifest.save());
|
||||
ui.close_menu();
|
||||
}
|
||||
if ui.button("Save As").clicked() {
|
||||
std::thread::spawn(|| -> crate::Result<()> {
|
||||
let mut dialog = rfd::FileDialog::new()
|
||||
.add_filter("Json", &["json"])
|
||||
.add_filter("Toml", &["toml"])
|
||||
.set_title("Save Manifest As")
|
||||
.set_can_create_directories(true)
|
||||
.set_file_name("manifest");
|
||||
if let Some(home_dir) = dirs::home_dir() {
|
||||
dialog = dialog.set_directory(home_dir);
|
||||
}
|
||||
if let Some(path) = dialog.save_file() {
|
||||
if let Some(ext) = path.extension() {
|
||||
TopNav::get()?.manifest_path = Some((path.clone(), ext.to_string_lossy().to_string()))
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
});
|
||||
}
|
||||
});
|
||||
ui.menu_button("Help", |ui| {
|
||||
if ui.button("Source").clicked() {
|
||||
ui.ctx().open_url(egui::OpenUrl::new_tab("https://git.mcorangehq.xyz/XOR64/music"));
|
||||
ui.close_menu();
|
||||
}
|
||||
|
||||
#[cfg(debug_assertions)]
|
||||
if ui.button("Debug").clicked() {
|
||||
state.windows.toggle(&WindowId::Debug, true);
|
||||
ui.close_menu();
|
||||
}
|
||||
|
||||
});
|
||||
#[cfg(debug_assertions)]
|
||||
ui.with_layout(Layout::right_to_left(egui::Align::Max), |ui| {
|
||||
ui.label(format!("ft: {} ms", state.debug_info.last_frame_time.as_millis()));
|
||||
});
|
||||
});
|
||||
});
|
||||
let mut used = false;
|
||||
if let Some((path, ext)) = &TopNav::get()?.manifest_path {
|
||||
match ext.as_str() {
|
||||
"json" => state.manifest.convert_and_save_to::<JsonStore>(&path)?,
|
||||
"toml" => state.manifest.convert_and_save_to::<TomlStore>(&path)?,
|
||||
_ => ()
|
||||
}
|
||||
used = true;
|
||||
}
|
||||
if used {
|
||||
TopNav::get()?.manifest_path = None;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -1,19 +0,0 @@
|
|||
// pub const APP_ICON: egui::ImageSource = egui::include_image!("../../assets/app_icon.png");
|
||||
// pub const APP_ICON_BYTES: &[u8] = include_bytes!("../../assets/app_icon.png");
|
||||
pub const NOTE_ICON: egui::ImageSource = egui::include_image!("../../assets/note.svg");
|
||||
pub const SEARCH_ICON: egui::ImageSource = egui::include_image!("../../assets/search.svg");
|
||||
pub const PREV_ICON: egui::ImageSource = egui::include_image!("../../assets/prev.svg");
|
||||
pub const NEXT_ICON: egui::ImageSource = egui::include_image!("../../assets/next.svg");
|
||||
pub const PLAY_ICON: egui::ImageSource = egui::include_image!("../../assets/play.svg");
|
||||
pub const PAUSE_ICON: egui::ImageSource = egui::include_image!("../../assets/pause.svg");
|
||||
pub const CHECK_ICON: egui::ImageSource = egui::include_image!("../../assets/check.svg");
|
||||
pub const DL_ICON: egui::ImageSource = egui::include_image!("../../assets/download.svg");
|
||||
pub const INFO_ICON: egui::ImageSource = egui::include_image!("../../assets/info.svg");
|
||||
pub const WARN_ICON: egui::ImageSource = egui::include_image!("../../assets/warning.svg");
|
||||
pub const ERROR_ICON: egui::ImageSource = egui::include_image!("../../assets/error.svg");
|
||||
pub const PLUS_ICON: egui::ImageSource = egui::include_image!("../../assets/plus.svg");
|
||||
pub const BURGER_ICON: egui::ImageSource = egui::include_image!("../../assets/burger_menu.svg");
|
||||
|
||||
|
||||
pub const C_WARN: egui::Color32 = egui::Color32::from_rgb(255, 183, 0); // #ffb700
|
||||
|
||||
|
|
@ -1,86 +0,0 @@
|
|||
use std::path::Path;
|
||||
use std::time::{Duration, Instant};
|
||||
use anyhow::anyhow;
|
||||
use xmpd_manifest::{store::JsonStore, Manifest};
|
||||
|
||||
#[macro_use]
|
||||
mod macros;
|
||||
mod main_window;
|
||||
mod windows;
|
||||
mod components;
|
||||
mod data;
|
||||
mod utils;
|
||||
|
||||
const W_NAME: &str = "xmpd v2.0.0a";
|
||||
|
||||
type Result<T> = anyhow::Result<T>;
|
||||
|
||||
pub fn start() -> Result<()> {
|
||||
let manifest_p = xmpd_settings::Settings::get()?.cache_settings.manifest_path.clone().into_std_path_buf();
|
||||
let cache_rx = xmpd_cache::Cache::get()
|
||||
.map_err(|e| anyhow!("Failed to get cache: {e}"))?
|
||||
.init()
|
||||
.map_err(|e| anyhow!("Failed to init cache: {e}"))?;
|
||||
|
||||
let options = eframe::NativeOptions::default();
|
||||
let mut state = GuiState::new(&manifest_p)?;
|
||||
let res = eframe::run_simple_native(W_NAME, options, move |ctx, _frame| {
|
||||
#[cfg(debug_assertions)]
|
||||
let f_start = Instant::now();
|
||||
|
||||
egui_extras::install_image_loaders(ctx);
|
||||
windows::Windows::draw_all(ctx, &mut state);
|
||||
handle_error_ui!(main_window::draw(ctx, &mut state, &cache_rx));
|
||||
ctx.request_repaint_after(Duration::from_millis(500));
|
||||
|
||||
#[cfg(debug_assertions)]
|
||||
{
|
||||
let f_end = Instant::now();
|
||||
state.debug_info.last_frame_time = f_end.duration_since(f_start);
|
||||
}
|
||||
});
|
||||
if let Err(e) = res { // dumb err value by eframe
|
||||
anyhow::bail!(e.to_string());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(debug_assertions)]
|
||||
#[derive(Debug, Default)]
|
||||
pub struct DebugInfo {
|
||||
pub last_frame_time: Duration,
|
||||
}
|
||||
|
||||
pub struct GuiState {
|
||||
#[cfg(debug_assertions)]
|
||||
pub debug_info: DebugInfo,
|
||||
pub manifest: Manifest<JsonStore>,
|
||||
pub windows: windows::Windows,
|
||||
pub player: xmpd_player::Player,
|
||||
}
|
||||
|
||||
impl GuiState {
|
||||
#[cfg(debug_assertions)]
|
||||
pub fn new(manifest_p: &Path) -> Result<Self> {
|
||||
Ok(Self {
|
||||
debug_info: DebugInfo {
|
||||
last_frame_time: Default::default()
|
||||
},
|
||||
player: xmpd_player::Player::new(),
|
||||
manifest: Manifest::new(manifest_p)?,
|
||||
windows: windows::Windows::new(),
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(not(debug_assertions))]
|
||||
pub fn new(manifest_p: &Path) -> Result<Self> {
|
||||
Ok(Self {
|
||||
player: xmpd_player::Player::new(),
|
||||
manifest: Manifest::new(manifest_p)?,
|
||||
windows: windows::Windows::new(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
|
@ -1,48 +0,0 @@
|
|||
|
||||
macro_rules! component_register {
|
||||
($comp:ident) => {
|
||||
lazy_static::lazy_static! {
|
||||
static ref __COMPONENT: std::sync::Arc<std::sync::Mutex<$comp>> =
|
||||
std::sync::Arc::new(std::sync::Mutex::new($comp::default()));
|
||||
}
|
||||
impl crate::components::CompGetter for $comp {
|
||||
fn get() -> crate::Result<std::sync::MutexGuard<'static, Self>> {
|
||||
match __COMPONENT.lock() {
|
||||
Ok(l) => Ok(l),
|
||||
Err(e) => Err(anyhow::anyhow!(format!("{e:?}"))),
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
macro_rules! handle_error_ui {
|
||||
($val:expr_2021) => {
|
||||
match $val {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
use crate::components::CompGetter;
|
||||
log::error!("Error in {}:{}: {e}", std::file!(), std::line!());
|
||||
if let Ok(mut toast) = crate::components::toast::Toast::get() {
|
||||
toast.show_toast(
|
||||
&format!("Error in {}:{}", std::file!(), std::line!()),
|
||||
&format!("{e}"),
|
||||
crate::components::toast::ToastType::Error,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
macro_rules! handle_option {
|
||||
($reason:expr_2021, $val:expr_2021) => {
|
||||
if let Some(v) = $val {
|
||||
v
|
||||
} else {
|
||||
handle_error_ui!(Err(anyhow::anyhow!($reason)));
|
||||
return;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
@ -1,106 +0,0 @@
|
|||
use std::sync::mpsc::Receiver;
|
||||
|
||||
use xmpd_cache::Message;
|
||||
use xmpd_manifest::store::BaseStore;
|
||||
use xmpd_settings::theme::Theme;
|
||||
|
||||
use crate::{components::{self, song_list, toast::ToastType, CompGetter, CompUi}, GuiState};
|
||||
|
||||
pub fn draw(ctx: &egui::Context, state: &mut GuiState, cache_rx: &Receiver<Message>) -> crate::Result<()> {
|
||||
let theme = xmpd_settings::Settings::get()?.theme.clone();
|
||||
egui::TopBottomPanel::new(egui::panel::TopBottomSide::Top, "top_nav")
|
||||
.frame(get_themed_frame(&theme))
|
||||
.show(ctx, |ui| {
|
||||
ui.style_mut().visuals.override_text_color = Some(theme.text_color);
|
||||
handle_error_ui!(crate::components::top_nav::TopNav::draw(ui, state));
|
||||
}
|
||||
);
|
||||
egui::CentralPanel::default()
|
||||
.frame(get_themed_frame(&theme))
|
||||
.show(ctx, |ui| {
|
||||
handle_error_ui!(components::toast::Toast::draw(ui, state));
|
||||
let avail = ui.available_size();
|
||||
ui.vertical(|ui| {
|
||||
crate::utils::super_separator(ui, theme.accent_color, avail.x, 2.0);
|
||||
let avail = ui.available_size();
|
||||
let main_height = avail.y * 0.91;
|
||||
|
||||
let left_nav_width = (avail.x * 0.25).clamp(0.0, 200.0);
|
||||
let song_list_width = avail.x - left_nav_width - 35.0;
|
||||
ui.horizontal(|ui| {
|
||||
ui.set_height(main_height);
|
||||
ui.vertical(|ui| {
|
||||
ui.set_height(main_height);
|
||||
ui.group(|ui| {
|
||||
//ui.set_height(main_height * 0.1);
|
||||
ui.set_max_width(left_nav_width);
|
||||
handle_error_ui!(crate::components::left_nav::header::Header::draw(ui, state));
|
||||
});
|
||||
let avail = ui.available_size();
|
||||
ui.group(|ui| {
|
||||
ui.set_height(avail.y);
|
||||
ui.set_max_width(left_nav_width);
|
||||
handle_error_ui!(crate::components::left_nav::LeftNav::draw(ui, state));
|
||||
});
|
||||
});
|
||||
ui.vertical(|ui| {
|
||||
ui.group(|ui| {
|
||||
ui.set_width(song_list_width);
|
||||
handle_error_ui!(crate::components::song_list::header::Header::draw(ui, state));
|
||||
});
|
||||
ui.group(|ui| {
|
||||
ui.set_width(song_list_width);
|
||||
handle_error_ui!(crate::components::song_list::SongList::draw(ui, state));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
egui::TopBottomPanel::new(egui::panel::TopBottomSide::Bottom, "player")
|
||||
.frame(get_themed_frame(&theme))
|
||||
.show(ctx, |ui| {
|
||||
ui.style_mut().visuals.override_text_color = Some(theme.text_color);
|
||||
handle_error_ui!(crate::components::player::Player::draw(ui, state));
|
||||
}
|
||||
);
|
||||
if let Ok(msg) = cache_rx.try_recv() {
|
||||
match msg {
|
||||
Message::DownloadDone(sid) => {
|
||||
if let Some(song) = state.manifest.store().get_song(&sid) {
|
||||
let mut toast = crate::components::toast::Toast::get()?;
|
||||
toast.show_toast(
|
||||
"Done downloading",
|
||||
&format!("Downloaded {} by {}", song.name(), song.author()),
|
||||
ToastType::Info
|
||||
);
|
||||
}
|
||||
}
|
||||
Message::Error(file, line, e) => {
|
||||
if let Ok(mut toast) = crate::components::toast::Toast::get() {
|
||||
toast.show_toast(
|
||||
&format!("Error in {file}:{line}"),
|
||||
&format!("{e}"),
|
||||
crate::components::toast::ToastType::Error,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn get_themed_frame(theme: &Theme) -> egui::Frame {
|
||||
egui::Frame::none()
|
||||
.fill(theme.primary_bg_color)
|
||||
.stroke(egui::Stroke::new(
|
||||
5.0,
|
||||
theme.secondary_bg_color,
|
||||
))
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -1,29 +0,0 @@
|
|||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum SearchType {
|
||||
Normal,
|
||||
Author,
|
||||
Source,
|
||||
}
|
||||
|
||||
impl SearchType {
|
||||
pub fn from_str(s: &str) -> (Self, String) {
|
||||
match s {
|
||||
i @ _ if i.starts_with("source:") =>
|
||||
(Self::Source, i.strip_prefix("source:").unwrap_or("").to_string().to_lowercase()),
|
||||
i @ _ if i.starts_with("author:") =>
|
||||
(Self::Author, i.strip_prefix("author:").unwrap_or("").to_string().to_lowercase()),
|
||||
i @ _ => (Self::Normal, i.to_string().to_lowercase())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn super_separator(ui: &mut egui::Ui, color: egui::Color32, width: f32, height: f32) {
|
||||
egui::Frame::none()
|
||||
.fill(color)
|
||||
.show(ui, |ui| {
|
||||
ui.set_width(width);
|
||||
ui.set_height(height);
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -1,153 +0,0 @@
|
|||
use egui::{RichText, Sense, TextEdit, TopBottomPanel};
|
||||
use xmpd_cache::DlStatus;
|
||||
use xmpd_manifest::{song::Song, store::{BaseStore, StoreExtras}};
|
||||
|
||||
use crate::{components::{CompGetter, toast::{Toast, ToastType}}, windows::WindowId};
|
||||
|
||||
use super::Window;
|
||||
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub struct AddSongW {
|
||||
sid: uuid::Uuid,
|
||||
pid: Option<uuid::Uuid>,
|
||||
}
|
||||
|
||||
impl Window for AddSongW {
|
||||
fn id() -> super::WindowId where Self: Sized {
|
||||
super::WindowId::AddSongToPl
|
||||
}
|
||||
fn default_title() -> &'static str where Self: Sized {
|
||||
"Add Song to Playlist"
|
||||
}
|
||||
fn draw(&mut self, ui: &mut egui::Ui, state: &mut crate::GuiState) -> crate::Result<()> {
|
||||
let mut save = false;
|
||||
self.pid = crate::components::left_nav::LeftNav::get()?.selected_playlist_id.clone();
|
||||
let theme = xmpd_settings::Settings::get()?.theme.clone();
|
||||
|
||||
let songs: Vec<_> = state.manifest.store().get_songs_sorted();
|
||||
if self.sid.is_nil() {
|
||||
if let Some(sid) = songs.first() {
|
||||
self.sid = sid.0.clone();
|
||||
}
|
||||
}
|
||||
let avail = ui.available_size();
|
||||
ui.horizontal(|ui| {
|
||||
ui.vertical(|ui| {
|
||||
ui.group(|ui| {
|
||||
ui.set_height(avail.y);
|
||||
let img = egui::Image::new(crate::data::NOTE_ICON)
|
||||
.tint(theme.accent_color)
|
||||
.fit_to_exact_size(egui::Vec2::new(64.0, 64.0));
|
||||
ui.add(img);
|
||||
let mut name = String::new();
|
||||
let mut author = String::new();
|
||||
if let Some(song) = state.manifest.store().get_song(&self.sid) {
|
||||
name = song.name().to_string();
|
||||
author = song.author().to_string();
|
||||
}
|
||||
ui.horizontal(|ui| {
|
||||
ui.style_mut().spacing.text_edit_width = 150.0;
|
||||
ui.label("Name: ");
|
||||
ui.add_enabled(false, TextEdit::singleline(&mut name));
|
||||
});
|
||||
ui.horizontal(|ui| {
|
||||
ui.style_mut().spacing.text_edit_width = 150.0;
|
||||
ui.label("Author: ");
|
||||
ui.add_enabled(false, TextEdit::singleline(&mut author));
|
||||
});
|
||||
});
|
||||
});
|
||||
ui.vertical(|ui| {
|
||||
ui.group(|ui| {
|
||||
egui::ScrollArea::vertical()
|
||||
.id_source("song_list_song_add")
|
||||
.drag_to_scroll(false)
|
||||
.show(ui, |ui| {
|
||||
ui.vertical(|ui| {
|
||||
for (sid, song) in songs {
|
||||
let resp = ui.group(|ui| {
|
||||
let avail = ui.available_size();
|
||||
ui.horizontal(|ui| {
|
||||
ui.set_width(avail.x);
|
||||
let img = egui::Image::new(crate::data::NOTE_ICON)
|
||||
.tint(theme.accent_color)
|
||||
.fit_to_exact_size(egui::Vec2::new(32.0, 32.0));
|
||||
ui.add(img);
|
||||
ui.vertical(|ui| {
|
||||
let status = {
|
||||
handle_error_ui!(xmpd_cache::Cache::get()).get_cached_song_status(&sid).clone()
|
||||
};
|
||||
let label = if self.sid == *sid {
|
||||
RichText::new(song.name())
|
||||
.color(theme.accent_color)
|
||||
} else if matches!(status, Some(DlStatus::Done(_))) {
|
||||
RichText::new(song.name())
|
||||
.color(theme.text_color)
|
||||
} else {
|
||||
RichText::new(song.name())
|
||||
.color(theme.dim_text_color)
|
||||
};
|
||||
ui.label(label);
|
||||
});
|
||||
});
|
||||
});
|
||||
if resp.response.interact(Sense::click()).clicked() {
|
||||
self.sid = sid.clone();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
);
|
||||
});
|
||||
let theme = handle_error_ui!(xmpd_settings::Settings::get()).theme.clone();
|
||||
TopBottomPanel::bottom("bottom_bar")
|
||||
.frame(
|
||||
egui::Frame::none()
|
||||
.fill(theme.primary_bg_color)
|
||||
.stroke(egui::Stroke::new(
|
||||
1.0,
|
||||
theme.secondary_bg_color,
|
||||
)),
|
||||
)
|
||||
.show(ui.ctx(), |ui| {
|
||||
ui.style_mut().visuals.override_text_color = Some(theme.text_color);
|
||||
|
||||
ui.add_space(3.0);
|
||||
ui.horizontal(|ui| {
|
||||
// ui.add_space(3.0);
|
||||
|
||||
|
||||
if ui.button("Add").clicked() {
|
||||
save = true;
|
||||
}
|
||||
|
||||
if ui.button("Cancel").clicked() {
|
||||
state.windows.toggle(&WindowId::AddSongToPl, false);
|
||||
}
|
||||
if ui.button("Close").clicked() {
|
||||
state.windows.toggle(&WindowId::AddSongToPl, false);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
})
|
||||
});
|
||||
if save {
|
||||
match &self.pid {
|
||||
Some(pid) => {
|
||||
let pl = state.manifest.store_mut().get_playlist_mut(pid);
|
||||
match pl {
|
||||
Some(pl) => pl.add_song(&self.sid),
|
||||
None => Toast::get().unwrap().show_toast("Not Allowed", "You cant add a song to the 'All Songs' playlist", ToastType::Error)
|
||||
};
|
||||
}
|
||||
None => (),
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn set_value<V>(&mut self, k: String, v: Box<V>) where Self: Sized {
|
||||
|
||||
}
|
||||
}
|
||||
|
|
@ -1,83 +0,0 @@
|
|||
use egui::RichText;
|
||||
|
||||
use crate::components::{toast::{self, ToastType}, CompGetter};
|
||||
|
||||
use super::Window;
|
||||
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub struct DebugW {
|
||||
toast_title: String,
|
||||
toast_descr: String,
|
||||
toast_type: ToastType,
|
||||
}
|
||||
|
||||
impl Window for DebugW {
|
||||
fn id() -> super::WindowId where Self: Sized {
|
||||
super::WindowId::Debug
|
||||
}
|
||||
fn default_title() -> &'static str where Self: Sized {
|
||||
"DEBUG WINDOW"
|
||||
}
|
||||
fn draw(&mut self, ui: &mut egui::Ui, _: &mut crate::GuiState) -> crate::Result<()> {
|
||||
ui.group(|ui| {
|
||||
ui.vertical(|ui| {
|
||||
ui.label(
|
||||
RichText::new("DEBUG")
|
||||
.heading()
|
||||
);
|
||||
ui.horizontal(|ui| {
|
||||
{
|
||||
ui.group(|ui| {
|
||||
ui.vertical(|ui| {
|
||||
ui.label(
|
||||
RichText::new("Toast")
|
||||
.heading()
|
||||
);
|
||||
Self::add_input_field(&mut self.toast_title, ui, "Title");
|
||||
Self::add_input_field(&mut self.toast_descr, ui, "Description");
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("Type:");
|
||||
egui::ComboBox::from_id_source("debug_combo")
|
||||
.selected_text(format!("{:?}", self.toast_type))
|
||||
.show_ui(ui, |ui| {
|
||||
ui.selectable_value(&mut self.toast_type, ToastType::Info, "Info");
|
||||
ui.selectable_value(&mut self.toast_type, ToastType::Warn, "Warn");
|
||||
ui.selectable_value(&mut self.toast_type, ToastType::Error, "Error");
|
||||
}
|
||||
);
|
||||
});
|
||||
if ui.button("Add").clicked() {
|
||||
toast::Toast::get().unwrap().show_toast(&self.toast_title, &self.toast_descr, self.toast_type);
|
||||
}
|
||||
if ui.button("Throw Error").clicked() {
|
||||
handle_error_ui!(Err(anyhow::anyhow!("{}: {}", self.toast_title, self.toast_descr)));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
fn set_value<V>(&mut self, k: String, v: Box<V>) where Self: Sized {
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
impl DebugW {
|
||||
fn add_input_field(inp: &mut String, ui: &mut egui::Ui, name: &str) {
|
||||
ui.horizontal(|ui|{
|
||||
ui.label(format!("{name}: "));
|
||||
ui.text_edit_singleline(inp);
|
||||
});
|
||||
}
|
||||
fn add_input_field_ml(inp: &mut String, ui: &mut egui::Ui, name: &str) {
|
||||
ui.horizontal(|ui|{
|
||||
ui.label(format!("{name}: "));
|
||||
ui.text_edit_multiline(inp);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -1,23 +0,0 @@
|
|||
use super::Window;
|
||||
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub struct ErrorW {
|
||||
|
||||
}
|
||||
|
||||
impl Window for ErrorW {
|
||||
fn id() -> super::WindowId where Self: Sized {
|
||||
super::WindowId::Error
|
||||
}
|
||||
fn default_title() -> &'static str where Self: Sized {
|
||||
"Error!"
|
||||
}
|
||||
fn draw(&mut self, ui: &mut egui::Ui, _: &mut crate::GuiState) -> crate::Result<()> {
|
||||
ui.label("Hello from other window!");
|
||||
Ok(())
|
||||
}
|
||||
fn set_value<V>(&mut self, k: String, v: Box<V>) where Self: Sized {
|
||||
|
||||
}
|
||||
}
|
||||
|
|
@ -1,114 +0,0 @@
|
|||
use std::{collections::{HashMap, HashSet}, sync::{Arc, Mutex}};
|
||||
use egui::{ViewportBuilder, ViewportId};
|
||||
use crate::GuiState;
|
||||
|
||||
#[cfg(debug_assertions)]
|
||||
pub mod debug;
|
||||
pub mod error;
|
||||
pub mod settings;
|
||||
pub mod add_song;
|
||||
pub mod new_song;
|
||||
pub mod new_playlist;
|
||||
|
||||
lazy_static::lazy_static!(
|
||||
pub static ref WINDOWS: Arc<Mutex<HashMap<WindowId, Box<dyn Window>>>> = Arc::new(Mutex::new(HashMap::new()));
|
||||
static ref OPEN_WINDOWS: Arc<Mutex<HashSet<WindowId>>> = Arc::new(Mutex::new(HashSet::new()));
|
||||
);
|
||||
|
||||
pub trait Window: std::fmt::Debug + Send {
|
||||
fn draw(&mut self, ui: &mut egui::Ui, state: &mut GuiState) -> crate::Result<()>;
|
||||
fn id() -> WindowId where Self: Sized;
|
||||
fn default_title() -> &'static str where Self: Sized;
|
||||
fn close(&self) where Self: Sized{
|
||||
OPEN_WINDOWS.lock().unwrap().remove(&Self::id());
|
||||
}
|
||||
fn set_value<V>(&mut self, k: String, v: Box<V>) where Self: Sized;
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Hash, PartialEq, PartialOrd, Ord, Eq)]
|
||||
pub enum WindowId {
|
||||
Settings,
|
||||
Error,
|
||||
#[cfg(debug_assertions)]
|
||||
Debug,
|
||||
NewPlaylist,
|
||||
NewSong,
|
||||
AddSongToPl,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Windows {
|
||||
windows: HashMap<WindowId, (ViewportId, ViewportBuilder)>,
|
||||
}
|
||||
|
||||
impl Windows {
|
||||
pub fn new() -> Self {
|
||||
let mut s = Self {
|
||||
windows: HashMap::new(),
|
||||
};
|
||||
s.add_all_windows();
|
||||
s
|
||||
}
|
||||
|
||||
pub fn add_all_windows(&mut self) {
|
||||
#[cfg(debug_assertions)]
|
||||
self.add_new_window::<debug::DebugW>();
|
||||
self.add_new_window::<error::ErrorW>();
|
||||
self.add_new_window::<settings::SettingsW>();
|
||||
self.add_new_window::<add_song::AddSongW>();
|
||||
self.add_new_window::<new_song::NewSongW>();
|
||||
self.add_new_window::<new_playlist::NewPlaylistW>();
|
||||
}
|
||||
|
||||
pub fn add_new_window<WT: Window + Default + 'static>(&mut self) {
|
||||
let builder = ViewportBuilder::default()
|
||||
.with_window_type(egui::X11WindowType::Dialog)
|
||||
.with_title(WT::default_title());
|
||||
self.windows.insert(WT::id(), (ViewportId::from_hash_of(WT::id()), builder));
|
||||
WINDOWS.lock().unwrap().insert(WT::id(), Box::<WT>::default());
|
||||
}
|
||||
|
||||
pub fn draw_all(ctx: &egui::Context, state: &mut GuiState) {
|
||||
let theme = handle_error_ui!(xmpd_settings::Settings::get()).theme.clone();
|
||||
for (win_id, (vp_id, builder)) in state.windows.windows.clone().into_iter() {
|
||||
if state.windows.is_open(&win_id) {
|
||||
ctx.show_viewport_immediate(vp_id.clone(), builder.clone(), |ctx, _vp_class| {
|
||||
ctx.input(|inp| {
|
||||
state.windows.toggle(&win_id, !inp.viewport().close_requested());
|
||||
});
|
||||
egui::CentralPanel::default()
|
||||
.frame(
|
||||
egui::Frame::none()
|
||||
.fill(theme.primary_bg_color)
|
||||
.stroke(egui::Stroke::new(
|
||||
1.0,
|
||||
theme.secondary_bg_color,
|
||||
)),
|
||||
)
|
||||
.show(ctx, |ui| {
|
||||
ui.style_mut().visuals.override_text_color = Some(theme.text_color);
|
||||
WINDOWS.lock().unwrap().get_mut(&win_id).unwrap().draw(ui, state)
|
||||
})
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn toggle(&self, id: &WindowId, status: bool) {
|
||||
if status {
|
||||
OPEN_WINDOWS.lock().unwrap().insert(id.clone());
|
||||
} else {
|
||||
OPEN_WINDOWS.lock().unwrap().remove(id);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_open(&self, id: &WindowId) -> bool {
|
||||
OPEN_WINDOWS.lock().unwrap().contains(&id)
|
||||
}
|
||||
|
||||
pub fn set_value(&self, id: &WindowId, k: impl ToString, v: impl ToString) -> crate::Result<()> {
|
||||
// WINDOWS.lock().unwrap().get_mut(&win_id).unwrap().set_value();
|
||||
//
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
|
@ -1,105 +0,0 @@
|
|||
use std::str::FromStr;
|
||||
|
||||
use egui::{Sense, Vec2};
|
||||
use xmpd_manifest::{playlist::{self, Playlist}, store::BaseStore};
|
||||
|
||||
use super::{Window, WindowId};
|
||||
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct NewPlaylistW {
|
||||
name: String,
|
||||
author: String,
|
||||
}
|
||||
|
||||
impl Default for NewPlaylistW {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
name: String::from("New Playlist"),
|
||||
author: String::from("Unknown"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Window for NewPlaylistW {
|
||||
fn id() -> WindowId where Self: Sized {
|
||||
WindowId::NewPlaylist
|
||||
}
|
||||
fn default_title() -> &'static str where Self: Sized {
|
||||
"New Playlist"
|
||||
}
|
||||
fn draw(&mut self, ui: &mut egui::Ui, state: &mut crate::GuiState) -> crate::Result<()> {
|
||||
let theme = xmpd_settings::Settings::get()?.theme.clone();
|
||||
let img_size = 64.0;
|
||||
let img_spacing = 10.0;
|
||||
ui.vertical(|ui| {
|
||||
ui.horizontal(|ui| {
|
||||
let mut rect = egui::Rect::ZERO;
|
||||
rect.set_width(img_size);
|
||||
rect.set_height(img_size);
|
||||
rect.set_top(img_spacing);
|
||||
rect.set_left(img_spacing);
|
||||
let rect_int = ui.interact(rect, "new_playlist_w".into(), Sense::click());
|
||||
if rect_int.hovered() {
|
||||
ui.allocate_ui_at_rect(rect, |ui| {
|
||||
ui.group(|ui| {
|
||||
let img = egui::Image::new(crate::data::PLUS_ICON)
|
||||
.tint(theme.accent_color)
|
||||
.fit_to_exact_size(Vec2::new(img_size, img_size));
|
||||
//.paint_at(ui, rect);
|
||||
ui.add(img);
|
||||
});
|
||||
});
|
||||
|
||||
} else {
|
||||
ui.allocate_ui_at_rect(rect, |ui| {
|
||||
ui.group(|ui| {
|
||||
let img = egui::Image::new(crate::data::NOTE_ICON)
|
||||
.tint(theme.accent_color)
|
||||
.fit_to_exact_size(Vec2::new(img_size, img_size));
|
||||
//.paint_at(ui, rect);
|
||||
ui.add(img);
|
||||
});
|
||||
});
|
||||
}
|
||||
if rect_int.clicked() {
|
||||
// TODO: Add a way to add custom icons
|
||||
}
|
||||
ui.vertical(|ui| {
|
||||
ui.add_space(img_spacing);
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("Name: ");
|
||||
ui.text_edit_singleline(&mut self.name);
|
||||
});
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("Author: ");
|
||||
ui.text_edit_singleline(&mut self.author);
|
||||
});
|
||||
});
|
||||
});
|
||||
ui.with_layout(egui::Layout::bottom_up(egui::Align::Max), |ui| {
|
||||
ui.add_space(3.0);
|
||||
ui.horizontal(|ui| {
|
||||
ui.add_space(3.0);
|
||||
if ui.button("Cancel").clicked() {
|
||||
self.author = String::from("New Playlist");
|
||||
self.name = String::from("Unknown");
|
||||
state.windows.toggle(&WindowId::NewPlaylist, false);
|
||||
}
|
||||
if ui.button("Add").clicked() {
|
||||
let mut playlist = Playlist::default();
|
||||
playlist.set_name(&self.name);
|
||||
playlist.set_author(&self.author);
|
||||
let playlists = state.manifest.store_mut().get_playlists_mut();
|
||||
playlists.insert(uuid::Uuid::new_v4(), playlist);
|
||||
state.windows.toggle(&WindowId::NewPlaylist, false);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
fn set_value<V>(&mut self, k: String, v: Box<V>) where Self: Sized {
|
||||
|
||||
}
|
||||
}
|
||||
|
|
@ -1,136 +0,0 @@
|
|||
use std::str::FromStr;
|
||||
|
||||
use egui::{Sense, Vec2};
|
||||
use xmpd_manifest::{song::{Song, SourceType}, store::BaseStore};
|
||||
|
||||
use crate::{components::{CompGetter, toast::{Toast, ToastType}}, windows::WindowId};
|
||||
|
||||
use super::Window;
|
||||
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub struct NewSongW {
|
||||
name: String,
|
||||
author: String,
|
||||
source_t: SourceType,
|
||||
source_url: String,
|
||||
source_url_old: String,
|
||||
}
|
||||
|
||||
impl Window for NewSongW {
|
||||
fn id() -> super::WindowId where Self: Sized {
|
||||
super::WindowId::NewSong
|
||||
}
|
||||
fn default_title() -> &'static str where Self: Sized {
|
||||
"New Song"
|
||||
}
|
||||
fn draw(&mut self, ui: &mut egui::Ui, state: &mut crate::GuiState) -> crate::Result<()> {
|
||||
let theme = xmpd_settings::Settings::get()?.theme.clone();
|
||||
let img_size = 64.0;
|
||||
let img_spacing = 10.0;
|
||||
ui.vertical(|ui| {
|
||||
let mut rect = egui::Rect::ZERO;
|
||||
rect.set_width(img_size);
|
||||
rect.set_height(img_size);
|
||||
rect.set_top(img_spacing);
|
||||
rect.set_left(img_spacing);
|
||||
let rect_int = ui.interact(rect, "new_playlist_w".into(), Sense::click());
|
||||
if rect_int.hovered() {
|
||||
ui.allocate_ui_at_rect(rect, |ui| {
|
||||
ui.group(|ui| {
|
||||
let img = egui::Image::new(crate::data::PLUS_ICON)
|
||||
.tint(theme.accent_color)
|
||||
.fit_to_exact_size(Vec2::new(img_size, img_size));
|
||||
//.paint_at(ui, rect);
|
||||
ui.add(img);
|
||||
});
|
||||
});
|
||||
|
||||
} else {
|
||||
ui.allocate_ui_at_rect(rect, |ui| {
|
||||
ui.group(|ui| {
|
||||
let img = egui::Image::new(crate::data::NOTE_ICON)
|
||||
.tint(theme.accent_color)
|
||||
.fit_to_exact_size(Vec2::new(img_size, img_size));
|
||||
//.paint_at(ui, rect);
|
||||
ui.add(img);
|
||||
});
|
||||
});
|
||||
}
|
||||
if rect_int.clicked() {
|
||||
// TODO: Add a way to add custom icons
|
||||
Toast::get().unwrap().show_toast("Not Implemented", "Adding icons to songs is not implemented", ToastType::Error);
|
||||
}
|
||||
ui.add_space(img_spacing);
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("Name: ");
|
||||
ui.text_edit_singleline(&mut self.name);
|
||||
});
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("Author: ");
|
||||
ui.text_edit_singleline(&mut self.author);
|
||||
});
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("Source Type: ");
|
||||
egui::ComboBox::new("new_song_song_t_sel", "")
|
||||
.selected_text(self.source_t.to_string())
|
||||
.show_ui(ui, |ui| {
|
||||
ui.selectable_value(&mut self.source_t, SourceType::Youtube, SourceType::Youtube.to_string());
|
||||
ui.selectable_value(&mut self.source_t, SourceType::Spotify, SourceType::Spotify.to_string());
|
||||
ui.selectable_value(&mut self.source_t, SourceType::Soundcloud, SourceType::Soundcloud.to_string());
|
||||
//ui.selectable_value(&mut self.source_t, SourceType::HttpBare, SourceType::HttpBare.to_string());
|
||||
//ui.selectable_value(&mut self.source_t, SourceType::HttpZip, SourceType::HttpZip.to_string());
|
||||
//ui.selectable_value(&mut self.source_t, SourceType::Http7z, SourceType::Http7z.to_string());
|
||||
}
|
||||
);
|
||||
});
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("Source URL: ");
|
||||
ui.text_edit_singleline(&mut self.source_url);
|
||||
if self.source_url != self.source_url_old {
|
||||
if let Some(t) = SourceType::from_url(&handle_error_ui!(url::Url::from_str(&self.source_url))) {
|
||||
self.source_t = t;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
ui.with_layout(egui::Layout::bottom_up(egui::Align::Max), |ui| {
|
||||
ui.add_space(3.0);
|
||||
ui.horizontal(|ui| {
|
||||
ui.add_space(3.0);
|
||||
if ui.button("Close").clicked() {
|
||||
self.author = String::from("New Song");
|
||||
self.name = String::from("Unknown");
|
||||
self.source_t = SourceType::Youtube;
|
||||
self.source_url = String::default();
|
||||
state.windows.toggle(&WindowId::NewSong, false);
|
||||
}
|
||||
|
||||
if ui.button("Add").clicked() {
|
||||
let mut s = handle_error_ui!(Song::new_from_str(&self.source_url, self.source_t));
|
||||
s.set_name(&self.name);
|
||||
s.set_author(&self.author);
|
||||
state.manifest.store_mut().get_songs_mut().insert(uuid::Uuid::new_v4(), s);
|
||||
|
||||
self.author = String::from("New Song");
|
||||
self.name = String::from("Unknown");
|
||||
self.source_t = SourceType::Youtube;
|
||||
self.source_url = String::default();
|
||||
}
|
||||
|
||||
if ui.button("Cancel").clicked() {
|
||||
self.author = String::from("New Song");
|
||||
self.name = String::from("Unknown");
|
||||
self.source_t = SourceType::Youtube;
|
||||
self.source_url = String::default();
|
||||
state.windows.toggle(&WindowId::NewSong, false);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
fn set_value<V>(&mut self, _: String, _: Box<V>) where Self: Sized {
|
||||
|
||||
}
|
||||
}
|
||||
|
|
@ -1,106 +0,0 @@
|
|||
use std::str::FromStr;
|
||||
|
||||
use super::Window;
|
||||
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SettingsW {
|
||||
ytdlp_p: String,
|
||||
spotdl_p: String,
|
||||
ffmpeg_p: String,
|
||||
song_fmt: String,
|
||||
}
|
||||
|
||||
impl Default for SettingsW {
|
||||
fn default() -> Self {
|
||||
let tooling = xmpd_settings::Settings::get().unwrap().tooling.clone();
|
||||
Self {
|
||||
ytdlp_p: tooling.ytdlp_path.to_string(),
|
||||
spotdl_p: tooling.spotdl_path.to_string(),
|
||||
ffmpeg_p: tooling.ffmpeg_path.to_string(),
|
||||
song_fmt: tooling.song_format
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
impl Window for SettingsW {
|
||||
fn id() -> super::WindowId where Self: Sized {
|
||||
super::WindowId::Settings
|
||||
}
|
||||
fn default_title() -> &'static str where Self: Sized {
|
||||
"Settings"
|
||||
}
|
||||
#[allow(irrefutable_let_patterns)]
|
||||
fn draw(&mut self, ui: &mut egui::Ui, _: &mut crate::GuiState) -> crate::Result<()> {
|
||||
ui.group(|ui| {
|
||||
ui.vertical(|ui| {
|
||||
ui.horizontal(|ui| {
|
||||
{
|
||||
let theme = &mut handle_error_ui!(xmpd_settings::Settings::get()).theme;
|
||||
ui.group(|ui| {
|
||||
ui.vertical(|ui| {
|
||||
ui.heading("Theme");
|
||||
Self::add_theme_button(&mut theme.accent_color, ui, "Accent");
|
||||
Self::add_theme_button(&mut theme.primary_bg_color, ui, "Primary BG");
|
||||
Self::add_theme_button(&mut theme.secondary_bg_color, ui, "Secondary BG");
|
||||
Self::add_theme_button(&mut theme.text_color, ui, "Text");
|
||||
Self::add_theme_button(&mut theme.dim_text_color, ui, "Dim Text");
|
||||
if ui.button("Reset").clicked() {
|
||||
*theme = xmpd_settings::theme::Theme::default();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
}
|
||||
{
|
||||
ui.group(|ui| {
|
||||
ui.vertical(|ui| {
|
||||
ui.heading("Tooling paths");
|
||||
Self::add_tooling_input(&mut self.ytdlp_p, ui, "stdlp");
|
||||
Self::add_tooling_input(&mut self.spotdl_p, ui, "spotdl");
|
||||
Self::add_tooling_input(&mut self.ffmpeg_p, ui, "ffmpeg");
|
||||
Self::add_tooling_input(&mut self.song_fmt, ui, "Format");
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
ui.with_layout(egui::Layout::bottom_up(egui::Align::RIGHT), |ui| {
|
||||
if ui.button("Save").clicked() {
|
||||
let mut settings = handle_error_ui!(xmpd_settings::Settings::get());
|
||||
if let Ok(p) = camino::Utf8PathBuf::from_str(&self.ytdlp_p) {
|
||||
settings.tooling.ytdlp_path = p;
|
||||
}
|
||||
if let Ok(p) = camino::Utf8PathBuf::from_str(&self.spotdl_p) {
|
||||
settings.tooling.spotdl_path = p;
|
||||
}
|
||||
if let Ok(p) = camino::Utf8PathBuf::from_str(&self.ffmpeg_p) {
|
||||
settings.tooling.ffmpeg_path = p;
|
||||
}
|
||||
settings.tooling.song_format.clone_from(&self.song_fmt);
|
||||
handle_error_ui!(settings.save(None));
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
fn set_value<V>(&mut self, k: String, v: Box<V>) where Self: Sized {
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
impl SettingsW {
|
||||
fn add_theme_button(rf: &mut egui::Color32, ui: &mut egui::Ui, name: &str) {
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(format!("{name}: "));
|
||||
ui.color_edit_button_srgba(rf);
|
||||
});
|
||||
}
|
||||
fn add_tooling_input(inp: &mut String, ui: &mut egui::Ui, name: &str) {
|
||||
ui.horizontal(|ui|{
|
||||
ui.label(format!("{name}: "));
|
||||
ui.text_edit_singleline(inp);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -1,28 +0,0 @@
|
|||
[package]
|
||||
name = "xmpd-manifest"
|
||||
edition.workspace = true
|
||||
readme="README.md"
|
||||
authors.workspace = true
|
||||
version.workspace = true
|
||||
repository.workspace = true
|
||||
license.workspace = true
|
||||
autobins = false
|
||||
autotests = false
|
||||
autoexamples = false
|
||||
|
||||
[features]
|
||||
default = []
|
||||
|
||||
[lib]
|
||||
crate-type = ["rlib"]
|
||||
bench = false
|
||||
|
||||
[dependencies]
|
||||
xmpd-cliargs.path = "../xmpd-cliargs"
|
||||
xmpd-settings.path = "../xmpd-settings"
|
||||
anyhow.workspace = true
|
||||
uuid.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
url.workspace = true
|
||||
toml.workspace = true
|
||||
|
|
@ -1,81 +0,0 @@
|
|||
use std::path::{Path, PathBuf};
|
||||
|
||||
use anyhow::anyhow;
|
||||
|
||||
|
||||
#[cfg(test)]
|
||||
pub mod tests;
|
||||
pub mod store;
|
||||
pub mod song;
|
||||
pub mod playlist;
|
||||
pub mod query;
|
||||
|
||||
pub type Result<T> = anyhow::Result<T>;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Manifest<ST: store::BaseStore> {
|
||||
store: Box<ST>,
|
||||
}
|
||||
|
||||
impl<ST: store::BaseStore + Clone> Manifest<ST> {
|
||||
pub fn new(p: &Path) -> Result<Self>{
|
||||
let mut store = ST::empty();
|
||||
if p.exists() {
|
||||
store.load_from(p)?;
|
||||
} else {
|
||||
store.save_to(p)?;
|
||||
}
|
||||
store.save_original_path(p);
|
||||
Ok(Self {
|
||||
store: Box::new(store)
|
||||
})
|
||||
}
|
||||
pub fn store(&self) -> &ST {
|
||||
self.store.as_ref()
|
||||
}
|
||||
pub fn store_mut(&mut self) -> &mut ST {
|
||||
self.store.as_mut()
|
||||
}
|
||||
pub fn save(&self) -> Result<()> {
|
||||
self.store().save_to(self.store().get_original_path())?;
|
||||
Ok(())
|
||||
}
|
||||
pub fn load(&mut self) -> Result<()> {
|
||||
let p = self.store().get_original_path().to_path_buf();
|
||||
self.store_mut().load_from(&p)?;
|
||||
Ok(())
|
||||
}
|
||||
pub fn convert_to<ST2: store::BaseStore>(&self) -> ST2 {
|
||||
let songs = self.store().get_songs().clone();
|
||||
let playlists = self.store().get_playlists().clone();
|
||||
let mut st2 = ST2::empty();
|
||||
*st2.get_songs_mut() = songs;
|
||||
*st2.get_playlists_mut() = playlists;
|
||||
st2
|
||||
}
|
||||
|
||||
pub fn convert_and_save_to<ST2: store::BaseStore>(&self, path: &Path) -> Result<()> {
|
||||
let st2 = self.convert_to::<ST2>();
|
||||
std::fs::write(path, st2.to_bytes()?)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn get_song_as_path(&self, sid: uuid::Uuid) -> Result<PathBuf> {
|
||||
let settings = &xmpd_settings::Settings::get()?;
|
||||
let ext = &settings.tooling.song_format;
|
||||
let mut p = settings.cache_settings.cache_path.clone();
|
||||
p.push("songs");
|
||||
p.push(sid.to_string());
|
||||
p.set_extension(ext);
|
||||
Ok(p.into_std_path_buf())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
@ -1,40 +0,0 @@
|
|||
use uuid::Uuid;
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, PartialOrd, Default)]
|
||||
pub struct Playlist {
|
||||
name: String,
|
||||
author: String,
|
||||
songs: Vec<Uuid>
|
||||
}
|
||||
|
||||
impl Playlist {
|
||||
pub fn name(&self) -> &str {
|
||||
&self.name
|
||||
}
|
||||
pub fn author(&self) -> &str {
|
||||
&self.author
|
||||
}
|
||||
pub fn songs(&self) -> &Vec<Uuid> {
|
||||
&self.songs
|
||||
}
|
||||
pub fn songs_mut(&mut self) -> &mut Vec<Uuid> {
|
||||
&mut self.songs
|
||||
}
|
||||
pub fn set_name(&mut self, v: &str) {
|
||||
self.name = v.to_string();
|
||||
}
|
||||
pub fn set_author(&mut self, v: &str) {
|
||||
self.author = v.to_string();
|
||||
}
|
||||
pub fn add_song(&mut self, v: &Uuid) {
|
||||
self.songs.push(v.clone());
|
||||
}
|
||||
pub fn remove_song(&mut self, v: &Uuid) {
|
||||
for (i, id) in self.songs.iter().enumerate() {
|
||||
if id == v {
|
||||
self.songs.remove(i);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,35 +0,0 @@
|
|||
use std::marker::PhantomData;
|
||||
|
||||
use uuid::Uuid;
|
||||
|
||||
|
||||
mod playlist;
|
||||
mod song;
|
||||
|
||||
pub struct Query<QT: ?Sized> {
|
||||
playlist: Option<Uuid>,
|
||||
song: Option<Uuid>,
|
||||
_phantom: PhantomData<QT>
|
||||
}
|
||||
|
||||
impl Query<()> {
|
||||
pub fn song(id: Uuid) -> Query<song::QuerySong> {
|
||||
Query {
|
||||
song: Some(id),
|
||||
playlist: None,
|
||||
_phantom: PhantomData
|
||||
}
|
||||
}
|
||||
pub fn playlist(id: Uuid) -> Query<playlist::QueryPlaylist> {
|
||||
Query {
|
||||
playlist: Some(id),
|
||||
song: None,
|
||||
_phantom: PhantomData
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
pub trait QueryType {}
|
||||
|
||||
|
||||