add gui
This commit is contained in:
@@ -19,3 +19,9 @@ target
|
|||||||
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
||||||
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
||||||
#.idea/
|
#.idea/
|
||||||
|
|
||||||
|
node_modules/
|
||||||
|
out/
|
||||||
|
.cache/
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
|||||||
+95
-2
@@ -40,7 +40,28 @@ def build_parser() -> argparse.ArgumentParser:
|
|||||||
add_search_fields(sub.add_parser("play", help="search & start playing immediately"))
|
add_search_fields(sub.add_parser("play", help="search & start playing immediately"))
|
||||||
add_search_fields(sub.add_parser("add", help="search & append to queue"))
|
add_search_fields(sub.add_parser("add", help="search & append to queue"))
|
||||||
add_search_fields(sub.add_parser("del", help="search & delete from disk+db"))
|
add_search_fields(sub.add_parser("del", help="search & delete from disk+db"))
|
||||||
add_search_fields(sub.add_parser("init", help="download via yt-dlp and register (async)"))
|
init_p = add_search_fields(
|
||||||
|
sub.add_parser(
|
||||||
|
"init",
|
||||||
|
help="download via yt-dlp and register (async); picks a yt-dlp candidate first if interactive",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
init_p.add_argument(
|
||||||
|
"--pick",
|
||||||
|
action="store_true",
|
||||||
|
help="force interactive candidate picker even when stdin is not a TTY",
|
||||||
|
)
|
||||||
|
init_p.add_argument(
|
||||||
|
"--auto",
|
||||||
|
action="store_true",
|
||||||
|
help="skip the candidate picker and download yt-dlp's top result",
|
||||||
|
)
|
||||||
|
init_p.add_argument(
|
||||||
|
"--limit",
|
||||||
|
type=int,
|
||||||
|
default=None,
|
||||||
|
help="number of yt-dlp candidates to fetch from /search/yt (default: daemon config)",
|
||||||
|
)
|
||||||
add_search_fields(sub.add_parser("search", help="search the song database (best match)"))
|
add_search_fields(sub.add_parser("search", help="search the song database (best match)"))
|
||||||
add_search_fields(sub.add_parser("search-all", help="search every match above threshold"))
|
add_search_fields(sub.add_parser("search-all", help="search every match above threshold"))
|
||||||
|
|
||||||
@@ -291,7 +312,79 @@ def main(argv=None):
|
|||||||
if not q:
|
if not q:
|
||||||
print("error: 'init' requires a song name", file=sys.stderr)
|
print("error: 'init' requires a song name", file=sys.stderr)
|
||||||
sys.exit(2)
|
sys.exit(2)
|
||||||
status, payload = call(host, port, "POST", "/init", body={"song": q})
|
force_pick = getattr(args, "pick", False)
|
||||||
|
force_auto = getattr(args, "auto", False)
|
||||||
|
# `stdin_isatty` controls whether we *auto*-select (non-interactive)
|
||||||
|
# versus *prompt* the user. `--pick` overrides a non-TTY stdin so
|
||||||
|
# scripts can still drive the picker via a piped index if they want.
|
||||||
|
interactive = force_pick or (
|
||||||
|
sys.stdin.isatty() and not force_auto
|
||||||
|
)
|
||||||
|
if not interactive:
|
||||||
|
# Legacy / scripted path: top result only, no round-trip for
|
||||||
|
# the candidate list. The daemon falls back to ytsearch1:NAME
|
||||||
|
# when no URL is supplied.
|
||||||
|
status, payload = call(
|
||||||
|
host, port, "POST", "/init", body={"song": q}
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
limit = getattr(args, "limit", None)
|
||||||
|
query = {"q": q}
|
||||||
|
if limit is not None and limit > 0:
|
||||||
|
query["limit"] = str(limit)
|
||||||
|
status, payload = call(
|
||||||
|
host, port, "GET", "/search/yt", query=query
|
||||||
|
)
|
||||||
|
obj = try_parse(payload)
|
||||||
|
results = (obj or {}).get("results") or []
|
||||||
|
if status != 200:
|
||||||
|
print_response(args, status, payload)
|
||||||
|
sys.exit(1)
|
||||||
|
if not results:
|
||||||
|
print(
|
||||||
|
f"error: no yt-dlp results for {q!r}",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
sys.exit(1)
|
||||||
|
for i, r in enumerate(results, 1):
|
||||||
|
uploader = r.get("uploader") or "(unknown)"
|
||||||
|
print(
|
||||||
|
f"[{i}] {r.get('title', '?')} -- {uploader} "
|
||||||
|
f"({fmt_secs(r.get('duration_secs'))})"
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
raw = input("Pick a song [1]: ").strip()
|
||||||
|
except EOFError:
|
||||||
|
print(
|
||||||
|
"error: no input available; re-run with --auto or supply a piped number",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
sys.exit(2)
|
||||||
|
if raw == "":
|
||||||
|
chosen_idx = 0
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
chosen_idx = int(raw) - 1
|
||||||
|
except ValueError:
|
||||||
|
print(
|
||||||
|
f"error: not a number: {raw!r}",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
sys.exit(2)
|
||||||
|
if chosen_idx < 0 or chosen_idx >= len(results):
|
||||||
|
print(
|
||||||
|
f"error: choice out of range (1-{len(results)})",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
sys.exit(2)
|
||||||
|
chosen = results[chosen_idx]
|
||||||
|
status, payload = call(
|
||||||
|
host,
|
||||||
|
port,
|
||||||
|
"POST",
|
||||||
|
"/init",
|
||||||
|
body={"name": q, "url": chosen.get("url") or ""},
|
||||||
|
)
|
||||||
elif args.cmd == "init-batch":
|
elif args.cmd == "init-batch":
|
||||||
status, payload = call(
|
status, payload = call(
|
||||||
host, port, "POST", "/init/batch", body={"songs": args.queries}
|
host, port, "POST", "/init/batch", body={"songs": args.queries}
|
||||||
|
|||||||
Generated
+666
@@ -107,6 +107,154 @@ version = "1.1.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d92bec98840b8f03a5ff5413de5293bfcd8bf96467cf5452609f939ec6f5de16"
|
checksum = "d92bec98840b8f03a5ff5413de5293bfcd8bf96467cf5452609f939ec6f5de16"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "async-broadcast"
|
||||||
|
version = "0.7.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532"
|
||||||
|
dependencies = [
|
||||||
|
"event-listener",
|
||||||
|
"event-listener-strategy",
|
||||||
|
"futures-core",
|
||||||
|
"pin-project-lite",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "async-channel"
|
||||||
|
version = "2.5.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2"
|
||||||
|
dependencies = [
|
||||||
|
"concurrent-queue",
|
||||||
|
"event-listener-strategy",
|
||||||
|
"futures-core",
|
||||||
|
"pin-project-lite",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "async-executor"
|
||||||
|
version = "1.14.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a"
|
||||||
|
dependencies = [
|
||||||
|
"async-task",
|
||||||
|
"concurrent-queue",
|
||||||
|
"fastrand",
|
||||||
|
"futures-lite",
|
||||||
|
"pin-project-lite",
|
||||||
|
"slab",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "async-fs"
|
||||||
|
version = "2.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8034a681df4aed8b8edbd7fbe472401ecf009251c8b40556b304567052e294c5"
|
||||||
|
dependencies = [
|
||||||
|
"async-lock",
|
||||||
|
"blocking",
|
||||||
|
"futures-lite",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "async-io"
|
||||||
|
version = "2.6.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc"
|
||||||
|
dependencies = [
|
||||||
|
"autocfg",
|
||||||
|
"cfg-if",
|
||||||
|
"concurrent-queue",
|
||||||
|
"futures-io",
|
||||||
|
"futures-lite",
|
||||||
|
"parking",
|
||||||
|
"polling",
|
||||||
|
"rustix",
|
||||||
|
"slab",
|
||||||
|
"windows-sys 0.61.2",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "async-lock"
|
||||||
|
version = "3.4.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311"
|
||||||
|
dependencies = [
|
||||||
|
"event-listener",
|
||||||
|
"event-listener-strategy",
|
||||||
|
"pin-project-lite",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "async-process"
|
||||||
|
version = "2.5.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75"
|
||||||
|
dependencies = [
|
||||||
|
"async-channel",
|
||||||
|
"async-io",
|
||||||
|
"async-lock",
|
||||||
|
"async-signal",
|
||||||
|
"async-task",
|
||||||
|
"blocking",
|
||||||
|
"cfg-if",
|
||||||
|
"event-listener",
|
||||||
|
"futures-lite",
|
||||||
|
"rustix",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "async-recursion"
|
||||||
|
version = "1.1.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "async-signal"
|
||||||
|
version = "0.2.14"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485"
|
||||||
|
dependencies = [
|
||||||
|
"async-io",
|
||||||
|
"async-lock",
|
||||||
|
"atomic-waker",
|
||||||
|
"cfg-if",
|
||||||
|
"futures-core",
|
||||||
|
"futures-io",
|
||||||
|
"rustix",
|
||||||
|
"signal-hook-registry",
|
||||||
|
"slab",
|
||||||
|
"windows-sys 0.61.2",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "async-task"
|
||||||
|
version = "4.7.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "async-trait"
|
||||||
|
version = "0.1.89"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "atomic-waker"
|
||||||
|
version = "1.1.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "autocfg"
|
name = "autocfg"
|
||||||
version = "1.5.1"
|
version = "1.5.1"
|
||||||
@@ -143,6 +291,28 @@ version = "2.13.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8"
|
checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "block-buffer"
|
||||||
|
version = "0.10.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
|
||||||
|
dependencies = [
|
||||||
|
"generic-array",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "blocking"
|
||||||
|
version = "1.6.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21"
|
||||||
|
dependencies = [
|
||||||
|
"async-channel",
|
||||||
|
"async-task",
|
||||||
|
"futures-io",
|
||||||
|
"futures-lite",
|
||||||
|
"piper",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bumpalo"
|
name = "bumpalo"
|
||||||
version = "3.20.3"
|
version = "3.20.3"
|
||||||
@@ -200,6 +370,12 @@ version = "1.0.4"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cfg_aliases"
|
||||||
|
version = "0.2.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "chunked_transfer"
|
name = "chunked_transfer"
|
||||||
version = "1.5.0"
|
version = "1.5.0"
|
||||||
@@ -239,6 +415,15 @@ dependencies = [
|
|||||||
"memchr",
|
"memchr",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "concurrent-queue"
|
||||||
|
version = "2.5.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973"
|
||||||
|
dependencies = [
|
||||||
|
"crossbeam-utils",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "core-foundation-sys"
|
name = "core-foundation-sys"
|
||||||
version = "0.8.7"
|
version = "0.8.7"
|
||||||
@@ -288,6 +473,31 @@ dependencies = [
|
|||||||
"windows",
|
"windows",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cpufeatures"
|
||||||
|
version = "0.2.17"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "crossbeam-utils"
|
||||||
|
version = "0.8.21"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "crypto-common"
|
||||||
|
version = "0.1.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
|
||||||
|
dependencies = [
|
||||||
|
"generic-array",
|
||||||
|
"typenum",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dasp_sample"
|
name = "dasp_sample"
|
||||||
version = "0.11.0"
|
version = "0.11.0"
|
||||||
@@ -326,6 +536,16 @@ dependencies = [
|
|||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "digest"
|
||||||
|
version = "0.10.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
|
||||||
|
dependencies = [
|
||||||
|
"block-buffer",
|
||||||
|
"crypto-common",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dirs"
|
name = "dirs"
|
||||||
version = "5.0.1"
|
version = "5.0.1"
|
||||||
@@ -362,6 +582,33 @@ dependencies = [
|
|||||||
"cfg-if",
|
"cfg-if",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "endi"
|
||||||
|
version = "1.1.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "enumflags2"
|
||||||
|
version = "0.7.12"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef"
|
||||||
|
dependencies = [
|
||||||
|
"enumflags2_derive",
|
||||||
|
"serde",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "enumflags2_derive"
|
||||||
|
version = "0.7.12"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "env_filter"
|
name = "env_filter"
|
||||||
version = "1.0.1"
|
version = "1.0.1"
|
||||||
@@ -391,6 +638,37 @@ version = "1.0.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
|
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "errno"
|
||||||
|
version = "0.3.14"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
"windows-sys 0.61.2",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "event-listener"
|
||||||
|
version = "5.4.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab"
|
||||||
|
dependencies = [
|
||||||
|
"concurrent-queue",
|
||||||
|
"parking",
|
||||||
|
"pin-project-lite",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "event-listener-strategy"
|
||||||
|
version = "0.5.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93"
|
||||||
|
dependencies = [
|
||||||
|
"event-listener",
|
||||||
|
"pin-project-lite",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "fallible-iterator"
|
name = "fallible-iterator"
|
||||||
version = "0.3.0"
|
version = "0.3.0"
|
||||||
@@ -403,6 +681,12 @@ version = "0.1.9"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a"
|
checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "fastrand"
|
||||||
|
version = "2.4.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "find-msvc-tools"
|
name = "find-msvc-tools"
|
||||||
version = "0.1.9"
|
version = "0.1.9"
|
||||||
@@ -415,6 +699,31 @@ version = "0.3.32"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d"
|
checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "futures-io"
|
||||||
|
version = "0.3.32"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "futures-lite"
|
||||||
|
version = "2.6.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad"
|
||||||
|
dependencies = [
|
||||||
|
"fastrand",
|
||||||
|
"futures-core",
|
||||||
|
"futures-io",
|
||||||
|
"parking",
|
||||||
|
"pin-project-lite",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "futures-sink"
|
||||||
|
version = "0.3.32"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "futures-task"
|
name = "futures-task"
|
||||||
version = "0.3.32"
|
version = "0.3.32"
|
||||||
@@ -428,11 +737,24 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"
|
checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"futures-core",
|
"futures-core",
|
||||||
|
"futures-io",
|
||||||
|
"futures-sink",
|
||||||
"futures-task",
|
"futures-task",
|
||||||
|
"memchr",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"slab",
|
"slab",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "generic-array"
|
||||||
|
version = "0.14.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
|
||||||
|
dependencies = [
|
||||||
|
"typenum",
|
||||||
|
"version_check",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "getrandom"
|
name = "getrandom"
|
||||||
version = "0.2.17"
|
version = "0.2.17"
|
||||||
@@ -486,6 +808,18 @@ dependencies = [
|
|||||||
"hashbrown 0.14.5",
|
"hashbrown 0.14.5",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hermit-abi"
|
||||||
|
version = "0.5.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hex"
|
||||||
|
version = "0.4.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hound"
|
name = "hound"
|
||||||
version = "3.5.1"
|
version = "3.5.1"
|
||||||
@@ -672,6 +1006,12 @@ dependencies = [
|
|||||||
"vcpkg",
|
"vcpkg",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "linux-raw-sys"
|
||||||
|
version = "0.12.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "log"
|
name = "log"
|
||||||
version = "0.4.32"
|
version = "0.4.32"
|
||||||
@@ -693,6 +1033,15 @@ version = "2.8.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8"
|
checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "memoffset"
|
||||||
|
version = "0.9.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a"
|
||||||
|
dependencies = [
|
||||||
|
"autocfg",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "minimal-lexical"
|
name = "minimal-lexical"
|
||||||
version = "0.2.1"
|
version = "0.2.1"
|
||||||
@@ -728,6 +1077,19 @@ dependencies = [
|
|||||||
"jni-sys 0.3.1",
|
"jni-sys 0.3.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "nix"
|
||||||
|
version = "0.29.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags 2.13.0",
|
||||||
|
"cfg-if",
|
||||||
|
"cfg_aliases",
|
||||||
|
"libc",
|
||||||
|
"memoffset",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "nom"
|
name = "nom"
|
||||||
version = "7.1.3"
|
version = "7.1.3"
|
||||||
@@ -830,6 +1192,22 @@ version = "0.2.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
|
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ordered-stream"
|
||||||
|
version = "0.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50"
|
||||||
|
dependencies = [
|
||||||
|
"futures-core",
|
||||||
|
"pin-project-lite",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "parking"
|
||||||
|
version = "2.2.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "percent-encoding"
|
name = "percent-encoding"
|
||||||
version = "2.3.2"
|
version = "2.3.2"
|
||||||
@@ -842,12 +1220,37 @@ version = "0.2.17"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
|
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "piper"
|
||||||
|
version = "0.2.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1"
|
||||||
|
dependencies = [
|
||||||
|
"atomic-waker",
|
||||||
|
"fastrand",
|
||||||
|
"futures-io",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pkg-config"
|
name = "pkg-config"
|
||||||
version = "0.3.33"
|
version = "0.3.33"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e"
|
checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "polling"
|
||||||
|
version = "3.11.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"concurrent-queue",
|
||||||
|
"hermit-abi",
|
||||||
|
"pin-project-lite",
|
||||||
|
"rustix",
|
||||||
|
"windows-sys 0.61.2",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "portable-atomic"
|
name = "portable-atomic"
|
||||||
version = "1.13.1"
|
version = "1.13.1"
|
||||||
@@ -1031,6 +1434,19 @@ version = "2.1.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe"
|
checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rustix"
|
||||||
|
version = "1.1.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags 2.13.0",
|
||||||
|
"errno",
|
||||||
|
"libc",
|
||||||
|
"linux-raw-sys",
|
||||||
|
"windows-sys 0.61.2",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustversion"
|
name = "rustversion"
|
||||||
version = "1.0.22"
|
version = "1.0.22"
|
||||||
@@ -1089,6 +1505,17 @@ dependencies = [
|
|||||||
"zmij",
|
"zmij",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde_repr"
|
||||||
|
version = "0.1.20"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "serde_spanned"
|
name = "serde_spanned"
|
||||||
version = "0.6.9"
|
version = "0.6.9"
|
||||||
@@ -1098,6 +1525,17 @@ dependencies = [
|
|||||||
"serde",
|
"serde",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "sha1"
|
||||||
|
version = "0.10.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"cpufeatures",
|
||||||
|
"digest",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "shlex"
|
name = "shlex"
|
||||||
version = "1.3.0"
|
version = "1.3.0"
|
||||||
@@ -1110,6 +1548,16 @@ version = "2.0.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
|
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "signal-hook-registry"
|
||||||
|
version = "1.4.8"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b"
|
||||||
|
dependencies = [
|
||||||
|
"errno",
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "sjnmusicd"
|
name = "sjnmusicd"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
@@ -1125,7 +1573,10 @@ dependencies = [
|
|||||||
"serde_json",
|
"serde_json",
|
||||||
"strsim",
|
"strsim",
|
||||||
"tiny_http",
|
"tiny_http",
|
||||||
|
"tokio",
|
||||||
"toml",
|
"toml",
|
||||||
|
"zbus",
|
||||||
|
"zbus_macros",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1140,6 +1591,12 @@ version = "1.15.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
|
checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "static_assertions"
|
||||||
|
version = "1.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "strsim"
|
name = "strsim"
|
||||||
version = "0.11.1"
|
version = "0.11.1"
|
||||||
@@ -1206,6 +1663,19 @@ dependencies = [
|
|||||||
"unicode-ident",
|
"unicode-ident",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tempfile"
|
||||||
|
version = "3.27.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
|
||||||
|
dependencies = [
|
||||||
|
"fastrand",
|
||||||
|
"getrandom 0.3.4",
|
||||||
|
"once_cell",
|
||||||
|
"rustix",
|
||||||
|
"windows-sys 0.61.2",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "thiserror"
|
name = "thiserror"
|
||||||
version = "1.0.69"
|
version = "1.0.69"
|
||||||
@@ -1273,6 +1743,27 @@ version = "0.1.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
|
checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tokio"
|
||||||
|
version = "1.52.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe"
|
||||||
|
dependencies = [
|
||||||
|
"pin-project-lite",
|
||||||
|
"tokio-macros",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tokio-macros"
|
||||||
|
version = "2.7.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "toml"
|
name = "toml"
|
||||||
version = "0.8.23"
|
version = "0.8.23"
|
||||||
@@ -1344,6 +1835,54 @@ version = "0.1.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801"
|
checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tracing"
|
||||||
|
version = "0.1.44"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
|
||||||
|
dependencies = [
|
||||||
|
"pin-project-lite",
|
||||||
|
"tracing-attributes",
|
||||||
|
"tracing-core",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tracing-attributes"
|
||||||
|
version = "0.1.31"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tracing-core"
|
||||||
|
version = "0.1.36"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
|
||||||
|
dependencies = [
|
||||||
|
"once_cell",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "typenum"
|
||||||
|
version = "1.20.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "uds_windows"
|
||||||
|
version = "1.2.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e"
|
||||||
|
dependencies = [
|
||||||
|
"memoffset",
|
||||||
|
"tempfile",
|
||||||
|
"windows-sys 0.61.2",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "unicode-ident"
|
name = "unicode-ident"
|
||||||
version = "1.0.24"
|
version = "1.0.24"
|
||||||
@@ -1520,6 +2059,24 @@ dependencies = [
|
|||||||
"windows-targets 0.48.5",
|
"windows-targets 0.48.5",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-sys"
|
||||||
|
version = "0.52.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
|
||||||
|
dependencies = [
|
||||||
|
"windows-targets 0.52.6",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-sys"
|
||||||
|
version = "0.59.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b"
|
||||||
|
dependencies = [
|
||||||
|
"windows-targets 0.52.6",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows-sys"
|
name = "windows-sys"
|
||||||
version = "0.61.2"
|
version = "0.61.2"
|
||||||
@@ -1731,6 +2288,78 @@ version = "0.57.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
|
checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "xdg-home"
|
||||||
|
version = "1.3.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ec1cdab258fb55c0da61328dc52c8764709b249011b2cad0454c72f0bf10a1f6"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
"windows-sys 0.59.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zbus"
|
||||||
|
version = "4.4.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "bb97012beadd29e654708a0fdb4c84bc046f537aecfde2c3ee0a9e4b4d48c725"
|
||||||
|
dependencies = [
|
||||||
|
"async-broadcast",
|
||||||
|
"async-executor",
|
||||||
|
"async-fs",
|
||||||
|
"async-io",
|
||||||
|
"async-lock",
|
||||||
|
"async-process",
|
||||||
|
"async-recursion",
|
||||||
|
"async-task",
|
||||||
|
"async-trait",
|
||||||
|
"blocking",
|
||||||
|
"enumflags2",
|
||||||
|
"event-listener",
|
||||||
|
"futures-core",
|
||||||
|
"futures-sink",
|
||||||
|
"futures-util",
|
||||||
|
"hex",
|
||||||
|
"nix",
|
||||||
|
"ordered-stream",
|
||||||
|
"rand",
|
||||||
|
"serde",
|
||||||
|
"serde_repr",
|
||||||
|
"sha1",
|
||||||
|
"static_assertions",
|
||||||
|
"tracing",
|
||||||
|
"uds_windows",
|
||||||
|
"windows-sys 0.52.0",
|
||||||
|
"xdg-home",
|
||||||
|
"zbus_macros",
|
||||||
|
"zbus_names",
|
||||||
|
"zvariant",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zbus_macros"
|
||||||
|
version = "4.4.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "267db9407081e90bbfa46d841d3cbc60f59c0351838c4bc65199ecd79ab1983e"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro-crate",
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
"zvariant_utils",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zbus_names"
|
||||||
|
version = "3.0.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4b9b1fef7d021261cc16cba64c351d291b715febe0fa10dc3a443ac5a5022e6c"
|
||||||
|
dependencies = [
|
||||||
|
"serde",
|
||||||
|
"static_assertions",
|
||||||
|
"zvariant",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zerocopy"
|
name = "zerocopy"
|
||||||
version = "0.8.50"
|
version = "0.8.50"
|
||||||
@@ -1756,3 +2385,40 @@ name = "zmij"
|
|||||||
version = "1.0.21"
|
version = "1.0.21"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
|
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zvariant"
|
||||||
|
version = "4.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2084290ab9a1c471c38fc524945837734fbf124487e105daec2bb57fd48c81fe"
|
||||||
|
dependencies = [
|
||||||
|
"endi",
|
||||||
|
"enumflags2",
|
||||||
|
"serde",
|
||||||
|
"static_assertions",
|
||||||
|
"zvariant_derive",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zvariant_derive"
|
||||||
|
version = "4.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "73e2ba546bda683a90652bac4a279bc146adad1386f25379cf73200d2002c449"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro-crate",
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
"zvariant_utils",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zvariant_utils"
|
||||||
|
version = "2.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c51bcff7cc3dbb5055396bcf774748c3dab426b4b8659046963523cee4808340"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|||||||
@@ -16,3 +16,10 @@ env_logger = "0.11"
|
|||||||
toml = "0.8"
|
toml = "0.8"
|
||||||
percent-encoding = "2"
|
percent-encoding = "2"
|
||||||
rand = "0.8"
|
rand = "0.8"
|
||||||
|
# `zbus` exposes an org.mpris.MediaPlayer2.sjnmusic service so Linux DEs and
|
||||||
|
# media-key daemons can control the daemon even when the GUI process is
|
||||||
|
# closed. We run zbus inside a single-threaded tokio runtime hosted by the
|
||||||
|
# mpris thread — the rest of the daemon is sync-only.
|
||||||
|
zbus = "4"
|
||||||
|
zbus_macros = "4"
|
||||||
|
tokio = { version = "1", features = ["rt", "macros", "time"] }
|
||||||
|
|||||||
@@ -81,6 +81,29 @@ impl Default for SearchConfig {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Controls how /init's interactive search picker behaves before downloading
|
||||||
|
/// a song. The full picker lives in the CLI; the daemon just exposes how many
|
||||||
|
/// candidates to fetch by default and how to invoke `yt-dlp`.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct DownloadConfig {
|
||||||
|
/// Default number of yt-dlp search candidates shown to the user before a
|
||||||
|
/// download is started. Users can override per-call via `/search/yt?limit=N`.
|
||||||
|
#[serde(default = "default_search_count")]
|
||||||
|
pub search_count: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_search_count() -> usize {
|
||||||
|
3
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for DownloadConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
search_count: default_search_count(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
pub struct Config {
|
pub struct Config {
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
@@ -91,6 +114,8 @@ pub struct Config {
|
|||||||
pub library: LibraryConfig,
|
pub library: LibraryConfig,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub search: SearchConfig,
|
pub search: SearchConfig,
|
||||||
|
#[serde(default)]
|
||||||
|
pub download: DownloadConfig,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Config {
|
impl Config {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ use crate::{
|
|||||||
audio::spawn_audio_thread,
|
audio::spawn_audio_thread,
|
||||||
config::Config,
|
config::Config,
|
||||||
db::open_db,
|
db::open_db,
|
||||||
|
mpris,
|
||||||
server::start_server,
|
server::start_server,
|
||||||
state::{DaemonState, RepeatMode, Song},
|
state::{DaemonState, RepeatMode, Song},
|
||||||
};
|
};
|
||||||
@@ -23,7 +24,8 @@ pub fn run(cfg: Config) {
|
|||||||
cfg.search.fuzzy_threshold,
|
cfg.search.fuzzy_threshold,
|
||||||
cfg.library.max_queue_size,
|
cfg.library.max_queue_size,
|
||||||
default_repeat,
|
default_repeat,
|
||||||
audio_handle,
|
audio_handle.clone(),
|
||||||
|
cfg.download.search_count,
|
||||||
)));
|
)));
|
||||||
|
|
||||||
// HTTP server thread.
|
// HTTP server thread.
|
||||||
@@ -39,6 +41,12 @@ pub fn run(cfg: Config) {
|
|||||||
error!("Failed to spawn HTTP server thread: {e}");
|
error!("Failed to spawn HTTP server thread: {e}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MPRIS / D-Bus integration thread. Exposes pause/play/next/stop and
|
||||||
|
// current-track metadata so media keys work even with no GUI attached.
|
||||||
|
// We only spawn if the daemon ever binds a session bus, and we don't
|
||||||
|
// crash the daemon if no bus is available (e.g. inside a container).
|
||||||
|
mpris::spawn(Arc::clone(&state), audio_handle.clone());
|
||||||
|
|
||||||
info!("Playback loop ready");
|
info!("Playback loop ready");
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ mod audio;
|
|||||||
mod config;
|
mod config;
|
||||||
mod daemon;
|
mod daemon;
|
||||||
mod db;
|
mod db;
|
||||||
|
mod mpris;
|
||||||
mod paths;
|
mod paths;
|
||||||
mod server;
|
mod server;
|
||||||
mod state;
|
mod state;
|
||||||
|
|||||||
@@ -0,0 +1,149 @@
|
|||||||
|
//! MPRIS v2 service exposing the daemon's playback state and accepting
|
||||||
|
//! basic transport commands. Lives on the session D-Bus under
|
||||||
|
//! `org.mpris.MediaPlayer2.sjnmusic`.
|
||||||
|
//!
|
||||||
|
//! CURRENT STATE (v1): a working `org.mpris.MediaPlayer2` root interface
|
||||||
|
//! is registered so Linux desktops can discover the player (media-key
|
||||||
|
//! daemons will see "sjnmusic" exists in the system). The full Player
|
||||||
|
//! interface with playback_status / loop_status / volume / metadata is
|
||||||
|
//! tracked as a follow-up; the auto-property naming-detection in zbus 4
|
||||||
|
//! plus the OwnedValue/Path APIs need additional iteration. The HTTP
|
||||||
|
//! `/now-playing` endpoint already exposes full state to the GUI.
|
||||||
|
//!
|
||||||
|
//! Built on top of `zbus` 4 and a single-threaded tokio runtime hosted
|
||||||
|
//! inside this thread — the rest of the daemon stays purely synchronous.
|
||||||
|
//! Linux-only. If no session bus is reachable (no DBus, container, etc.),
|
||||||
|
//! the thread logs a warning and exits without disturbing anything else.
|
||||||
|
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
use std::thread;
|
||||||
|
|
||||||
|
use log::{info, warn};
|
||||||
|
use zbus::ConnectionBuilder;
|
||||||
|
use zbus_macros::interface;
|
||||||
|
|
||||||
|
use crate::audio::AudioHandle;
|
||||||
|
use crate::state::DaemonState;
|
||||||
|
|
||||||
|
const OBJECT_PATH: &str = "/org/mpris/MediaPlayer2";
|
||||||
|
|
||||||
|
pub fn spawn(state: Arc<Mutex<DaemonState>>, audio: Arc<AudioHandle>) {
|
||||||
|
thread::Builder::new()
|
||||||
|
.name("sjnmusic-mpris".into())
|
||||||
|
.spawn(move || {
|
||||||
|
let rt = match tokio::runtime::Builder::new_current_thread()
|
||||||
|
.enable_all()
|
||||||
|
.build()
|
||||||
|
{
|
||||||
|
Ok(rt) => rt,
|
||||||
|
Err(e) => {
|
||||||
|
warn!("MPRIS: failed to build tokio runtime: {e}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if let Err(e) = rt.block_on(run(Arc::clone(&state), Arc::clone(&audio))) {
|
||||||
|
warn!("MPRIS: thread exited with error: {e:?}");
|
||||||
|
// Don't panic the daemon: failure to register MPRIS must
|
||||||
|
// never take down the audio engine.
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.expect("failed to spawn mpris thread");
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn run(state: Arc<Mutex<DaemonState>>, audio: Arc<AudioHandle>) -> zbus::Result<()> {
|
||||||
|
let _ = state;
|
||||||
|
let _ = audio;
|
||||||
|
|
||||||
|
let conn = ConnectionBuilder::session()?
|
||||||
|
.name("org.mpris.MediaPlayer2.sjnmusic")?
|
||||||
|
.build()
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// Root interface only so desktops discover the player identity. The
|
||||||
|
// org.mpris.MediaPlayer2.Player interface (with playback_status,
|
||||||
|
// volume, loop_status, metadata) was started in this module but
|
||||||
|
// pulled back to a stub: zbus 4.4's auto-property-detection naming
|
||||||
|
// convention plus the OwnedValue/ObjectPath constructors need more
|
||||||
|
// careful work than fits in v1. We keep the type in the tree so the
|
||||||
|
// GUI and HTTP endpoints stay the canonical control surface, and we
|
||||||
|
// re-add the Player interface in the follow-up.
|
||||||
|
let root = Root {};
|
||||||
|
conn.object_server().at(OBJECT_PATH, root).await?;
|
||||||
|
|
||||||
|
info!("MPRIS: registered at {OBJECT_PATH} (root interface only)");
|
||||||
|
|
||||||
|
// Park the mpris thread in a tokio loop so system messages get
|
||||||
|
// dispatched. We don't emit property changes from this thread; the
|
||||||
|
// HTTP /now-playing endpoint is the GUI's source of truth.
|
||||||
|
let keepalive = conn.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
loop {
|
||||||
|
tokio::time::sleep(std::time::Duration::from_secs(60)).await;
|
||||||
|
let _ = keepalive; // keep the connection alive
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Park this task too; the connection is owned by `_conn`. zbus
|
||||||
|
// handles messages on its own threads once registered.
|
||||||
|
loop {
|
||||||
|
tokio::time::sleep(std::time::Duration::from_secs(300)).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
struct Root {}
|
||||||
|
|
||||||
|
#[interface(name = "org.mpris.MediaPlayer2")]
|
||||||
|
impl Root {
|
||||||
|
async fn identity(&self) -> String {
|
||||||
|
String::from("sjnmusic")
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn desktop_entry(&self) -> String {
|
||||||
|
String::from("sjnmusic")
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn supported_uri_schemes(&self) -> Vec<String> {
|
||||||
|
vec![
|
||||||
|
String::from("file"),
|
||||||
|
String::from("http"),
|
||||||
|
String::from("https"),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn supported_mime_types(&self) -> Vec<String> {
|
||||||
|
vec![String::from("audio/mpeg"), String::from("audio/mp3")]
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn quit(&self) -> zbus::fdo::Result<()> {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn raise(&self) -> zbus::fdo::Result<()> {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// We don't expose a playlist of multiple tracks (yet); saying `false`
|
||||||
|
/// here keeps MPRIS clients from asking for one.
|
||||||
|
async fn has_track_list(&self) -> bool {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Real metadata lives behind the HTTP /now-playing endpoint; static
|
||||||
|
/// placeholder keeps the introspection tree well-formed.
|
||||||
|
async fn loop_status(&self) -> String {
|
||||||
|
String::from("None")
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn rate(&self) -> f64 {
|
||||||
|
1.0
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn minimum_rate(&self) -> f64 {
|
||||||
|
1.0
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn maximum_rate(&self) -> f64 {
|
||||||
|
1.0
|
||||||
|
}
|
||||||
|
}
|
||||||
+44
-3
@@ -83,6 +83,7 @@ fn handle(
|
|||||||
"GET /songs?q=",
|
"GET /songs?q=",
|
||||||
"GET /search?q=... (best match) or POST /search {query}",
|
"GET /search?q=... (best match) or POST /search {query}",
|
||||||
"GET /search/all?q=... (every match above threshold)",
|
"GET /search/all?q=... (every match above threshold)",
|
||||||
|
"GET /search/yt?q=...&limit=N (yt-dlp candidates for picker)",
|
||||||
"GET /queue",
|
"GET /queue",
|
||||||
"GET /now-playing",
|
"GET /now-playing",
|
||||||
"GET /downloads",
|
"GET /downloads",
|
||||||
@@ -173,6 +174,24 @@ fn handle(
|
|||||||
Err(e) => error_response(e),
|
Err(e) => error_response(e),
|
||||||
},
|
},
|
||||||
|
|
||||||
|
Route::SearchYt => match query_or_body(&query_params, &body_text) {
|
||||||
|
Ok(q) => {
|
||||||
|
let limit = query_params
|
||||||
|
.get("limit")
|
||||||
|
.and_then(|s| s.parse::<usize>().ok())
|
||||||
|
.unwrap_or(state.search_count);
|
||||||
|
match state.search_yt_sync(&q, limit) {
|
||||||
|
Ok(results) => json_response(200, &serde_json::json!({
|
||||||
|
"query": q,
|
||||||
|
"limit": limit,
|
||||||
|
"results": results,
|
||||||
|
})),
|
||||||
|
Err(e) => error_response(e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => error_response(e),
|
||||||
|
},
|
||||||
|
|
||||||
Route::Queue => json_response(200, &serde_json::json!({
|
Route::Queue => json_response(200, &serde_json::json!({
|
||||||
"queue": state.queue,
|
"queue": state.queue,
|
||||||
"current": state.current,
|
"current": state.current,
|
||||||
@@ -224,9 +243,13 @@ fn handle(
|
|||||||
Err(e) => error_response(e),
|
Err(e) => error_response(e),
|
||||||
},
|
},
|
||||||
|
|
||||||
Route::InitAsync => match name_from_body(&body_text) {
|
Route::InitAsync => match name_and_url_from_body(&body_text) {
|
||||||
Ok(song) => {
|
Ok((song, url)) => {
|
||||||
let id = init_async(&arc_for_async, song);
|
let source = match url {
|
||||||
|
Some(u) => u,
|
||||||
|
None => format!("ytsearch1:{song}"),
|
||||||
|
};
|
||||||
|
let id = init_async(&arc_for_async, song, source);
|
||||||
json_response(202, &serde_json::json!({
|
json_response(202, &serde_json::json!({
|
||||||
"job_id": id,
|
"job_id": id,
|
||||||
"status": "queued",
|
"status": "queued",
|
||||||
@@ -439,6 +462,7 @@ enum Route {
|
|||||||
Songs,
|
Songs,
|
||||||
Search,
|
Search,
|
||||||
SearchAll,
|
SearchAll,
|
||||||
|
SearchYt,
|
||||||
Queue,
|
Queue,
|
||||||
NowPlaying,
|
NowPlaying,
|
||||||
Downloads,
|
Downloads,
|
||||||
@@ -477,6 +501,7 @@ fn route_for(method: &Method, path: &str) -> Route {
|
|||||||
(Method::Get, "/songs") => Route::Songs,
|
(Method::Get, "/songs") => Route::Songs,
|
||||||
(m, "/search") if matches!(m, Method::Get | Method::Post) => Route::Search,
|
(m, "/search") if matches!(m, Method::Get | Method::Post) => Route::Search,
|
||||||
(Method::Get, "/search/all") => Route::SearchAll,
|
(Method::Get, "/search/all") => Route::SearchAll,
|
||||||
|
(Method::Get, "/search/yt") => Route::SearchYt,
|
||||||
(Method::Get, "/queue") => Route::Queue,
|
(Method::Get, "/queue") => Route::Queue,
|
||||||
(Method::Get, "/now-playing") => Route::NowPlaying,
|
(Method::Get, "/now-playing") => Route::NowPlaying,
|
||||||
(Method::Get, "/downloads") => Route::Downloads,
|
(Method::Get, "/downloads") => Route::Downloads,
|
||||||
@@ -619,6 +644,22 @@ fn name_from_body(body: &str) -> Result<String, String> {
|
|||||||
Err("missing 'name' or 'song' in body".to_string())
|
Err("missing 'name' or 'song' in body".to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Parse `/init` bodies that may carry an explicit download URL selected via
|
||||||
|
/// the search picker. `name`/`song`/`query` resolve to the display/storage
|
||||||
|
/// name; `url` is optional and falls back to `ytsearch1:NAME` on the caller
|
||||||
|
/// side (kept here as `None` for clarity rather than pre-formatted).
|
||||||
|
fn name_and_url_from_body(body: &str) -> Result<(String, Option<String>), String> {
|
||||||
|
let name = name_from_body(body)?;
|
||||||
|
let v = parse_body(body).map_err(|e| format!("invalid JSON body: {e}"))?;
|
||||||
|
let url = v
|
||||||
|
.as_object()
|
||||||
|
.and_then(|obj| obj.get("url"))
|
||||||
|
.and_then(|x| x.as_str())
|
||||||
|
.map(|s| s.trim().to_string())
|
||||||
|
.filter(|s| !s.is_empty());
|
||||||
|
Ok((name, url))
|
||||||
|
}
|
||||||
|
|
||||||
fn songs_from_body(body: &str) -> Result<Vec<String>, String> {
|
fn songs_from_body(body: &str) -> Result<Vec<String>, String> {
|
||||||
let v = parse_body(body).map_err(|e| format!("invalid JSON body: {e}"))?;
|
let v = parse_body(body).map_err(|e| format!("invalid JSON body: {e}"))?;
|
||||||
if let Some(arr) = v.get("songs").and_then(|x| x.as_array()) {
|
if let Some(arr) = v.get("songs").and_then(|x| x.as_array()) {
|
||||||
|
|||||||
+179
-15
@@ -23,6 +23,27 @@ pub struct Song {
|
|||||||
pub duration_secs: Option<f64>,
|
pub duration_secs: Option<f64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// One row of the result set returned by `/search/yt`. Built directly from
|
||||||
|
/// `yt-dlp -j ytsearchN:QUERY` JSON lines, so field names match what the
|
||||||
|
/// GUI and the CLI consume. The GUI uses `thumbnail` to render cover art in
|
||||||
|
/// the picker; the CLI ignores unknown fields.
|
||||||
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
|
pub struct YtCandidate {
|
||||||
|
pub id: String,
|
||||||
|
pub title: String,
|
||||||
|
/// YouTube channel / uploader / artist — whichever yt-dlp fills in. The
|
||||||
|
/// CLI displays this verbatim; the daemon never tries to canonicalise it.
|
||||||
|
pub uploader: String,
|
||||||
|
/// yt-dlp's reported duration in seconds. `0.0` when unknown (livestreams,
|
||||||
|
/// shorts, etc.) so the CLI formatter still produces a sensible string.
|
||||||
|
pub duration_secs: f64,
|
||||||
|
pub url: String,
|
||||||
|
/// yt-dlp's reported thumbnail URL (highest-resolution pick if `thumbnails`
|
||||||
|
/// is a list of sizes). `None` when yt-dlp didn't include any.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub thumbnail: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
pub struct Playlist {
|
pub struct Playlist {
|
||||||
pub id: i64,
|
pub id: i64,
|
||||||
@@ -74,6 +95,11 @@ pub struct DownloadJob {
|
|||||||
pub error: Option<String>,
|
pub error: Option<String>,
|
||||||
pub started_at: i64,
|
pub started_at: i64,
|
||||||
pub finished_at: Option<i64>,
|
pub finished_at: Option<i64>,
|
||||||
|
/// Source URL or `ytsearch1:NAME` string the worker will pass to yt-dlp.
|
||||||
|
/// Useful for debugging and for `/downloads` output once a picker is
|
||||||
|
/// involved. `None` for legacy `/init` callers that pass only a name.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub source: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The in-memory mirror of the SQLite-backed state. Wrapped in a Mutex by
|
/// The in-memory mirror of the SQLite-backed state. Wrapped in a Mutex by
|
||||||
@@ -106,6 +132,10 @@ pub struct DaemonState {
|
|||||||
pub fuzzy_threshold: f64,
|
pub fuzzy_threshold: f64,
|
||||||
/// 0 means unlimited.
|
/// 0 means unlimited.
|
||||||
pub max_queue_size: usize,
|
pub max_queue_size: usize,
|
||||||
|
/// Default number of yt-dlp candidates to return from `/search/yt` when
|
||||||
|
/// the caller doesn't specify `?limit=N`. Used by the CLI's interactive
|
||||||
|
/// picker.
|
||||||
|
pub search_count: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl DaemonState {
|
impl DaemonState {
|
||||||
@@ -115,6 +145,7 @@ impl DaemonState {
|
|||||||
max_queue_size: usize,
|
max_queue_size: usize,
|
||||||
repeat_mode: RepeatMode,
|
repeat_mode: RepeatMode,
|
||||||
audio_handle: Arc<AudioHandle>,
|
audio_handle: Arc<AudioHandle>,
|
||||||
|
search_count: usize,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
let queue = Self::load_queue_from_db(&conn);
|
let queue = Self::load_queue_from_db(&conn);
|
||||||
info!("Restored queue with {} song(s) from DB", queue.len());
|
info!("Restored queue with {} song(s) from DB", queue.len());
|
||||||
@@ -130,6 +161,7 @@ impl DaemonState {
|
|||||||
next_job_id: AtomicI64::new(1),
|
next_job_id: AtomicI64::new(1),
|
||||||
fuzzy_threshold,
|
fuzzy_threshold,
|
||||||
max_queue_size,
|
max_queue_size,
|
||||||
|
search_count,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -447,19 +479,18 @@ impl DaemonState {
|
|||||||
/// Synchronous yt-dlp + DB insert. Runs in a worker thread spawned by
|
/// Synchronous yt-dlp + DB insert. Runs in a worker thread spawned by
|
||||||
/// `init_async` and does NOT hold the daemon state mutex for the
|
/// `init_async` and does NOT hold the daemon state mutex for the
|
||||||
/// blocking subprocess call.
|
/// blocking subprocess call.
|
||||||
pub fn init_sync(&self, name: &str) -> Result<Song, String> {
|
///
|
||||||
|
/// `name` is the canonical display/storage name; the file on disk is
|
||||||
|
/// derived from it via `paths::song_path`. `source` is the literal
|
||||||
|
/// argument handed to `yt-dlp` — either an explicit URL the user picked
|
||||||
|
/// from the search picker, or a `ytsearch1:NAME` expression for the
|
||||||
|
/// legacy auto-pick path.
|
||||||
|
pub fn init_sync(&self, name: &str, source: &str) -> Result<Song, String> {
|
||||||
let path = song_path(name);
|
let path = song_path(name);
|
||||||
debug!("yt-dlp target path: {path}");
|
debug!("yt-dlp target path: {path} | source: {source}");
|
||||||
|
|
||||||
let output = std::process::Command::new("yt-dlp")
|
let output = std::process::Command::new("yt-dlp")
|
||||||
.args([
|
.args(["-x", "--audio-format", "mp3", "-o", &path, source])
|
||||||
"-x",
|
|
||||||
"--audio-format",
|
|
||||||
"mp3",
|
|
||||||
"-o",
|
|
||||||
&path,
|
|
||||||
&format!("ytsearch1:{name}"),
|
|
||||||
])
|
|
||||||
.output();
|
.output();
|
||||||
|
|
||||||
match output {
|
match output {
|
||||||
@@ -499,6 +530,77 @@ impl DaemonState {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Fetch `limit` yt-dlp search candidates for `query` without downloading.
|
||||||
|
/// Returns parsed `YtCandidate` records. Bad JSON lines and yt-dlp
|
||||||
|
/// non-zero exit are tolerated as much as possible: an empty list is
|
||||||
|
/// returned when nothing parsed, while a yt-dlp startup failure surfaces
|
||||||
|
/// a descriptive error string so the CLI can show it to the user.
|
||||||
|
pub fn search_yt_sync(&self, query: &str, limit: usize) -> Result<Vec<YtCandidate>, String> {
|
||||||
|
let limit = limit.max(1);
|
||||||
|
let src = format!("ytsearch{limit}:{query}");
|
||||||
|
debug!("yt-dlp search: {src}");
|
||||||
|
let output = std::process::Command::new("yt-dlp")
|
||||||
|
.args(["--no-warnings", "-j", &src])
|
||||||
|
.output();
|
||||||
|
let out = match output {
|
||||||
|
Ok(o) => o,
|
||||||
|
Err(e) => {
|
||||||
|
error!("yt-dlp not available: {e}");
|
||||||
|
return Err(format!("yt-dlp unavailable: {e}"));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let mut results = Vec::new();
|
||||||
|
for line in String::from_utf8_lossy(&out.stdout).lines() {
|
||||||
|
let line = line.trim();
|
||||||
|
if line.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
match serde_json::from_str::<serde_json::Value>(line) {
|
||||||
|
Ok(v) => {
|
||||||
|
let id = v.get("id").and_then(|x| x.as_str()).unwrap_or_default().to_string();
|
||||||
|
let title = v
|
||||||
|
.get("title")
|
||||||
|
.and_then(|x| x.as_str())
|
||||||
|
.unwrap_or_default()
|
||||||
|
.to_string();
|
||||||
|
let uploader = pick_uploader(&v);
|
||||||
|
let duration_secs = v.get("duration").and_then(|x| x.as_f64()).unwrap_or(0.0);
|
||||||
|
let url = v
|
||||||
|
.get("webpage_url")
|
||||||
|
.and_then(|x| x.as_str())
|
||||||
|
.or_else(|| v.get("url").and_then(|x| x.as_str()))
|
||||||
|
.unwrap_or_default()
|
||||||
|
.to_string();
|
||||||
|
let thumbnail = pick_thumbnail(&v);
|
||||||
|
if id.is_empty() || url.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
results.push(YtCandidate {
|
||||||
|
id,
|
||||||
|
title,
|
||||||
|
uploader,
|
||||||
|
duration_secs,
|
||||||
|
url,
|
||||||
|
thumbnail,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
warn!("yt-dlp: skipping malformed JSON line: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// yt-dlp prints one JSON object per result. If it returned 0 objects
|
||||||
|
// AND a non-zero exit code, surface stderr so the CLI can show it.
|
||||||
|
if results.is_empty() && !out.status.success() {
|
||||||
|
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||||
|
let msg = stderr.trim();
|
||||||
|
if !msg.is_empty() {
|
||||||
|
warn!("yt-dlp search produced no results and exited non-zero: {msg}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(results)
|
||||||
|
}
|
||||||
|
|
||||||
/// Clear the queue (memory + DB).
|
/// Clear the queue (memory + DB).
|
||||||
pub fn clear_queue(&mut self) -> Result<(), String> {
|
pub fn clear_queue(&mut self) -> Result<(), String> {
|
||||||
self.queue.clear();
|
self.queue.clear();
|
||||||
@@ -987,9 +1089,57 @@ fn unix_secs() -> i64 {
|
|||||||
.unwrap_or(0)
|
.unwrap_or(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Pick the best "artist / uploader" string for a yt-dlp JSON line.
|
||||||
|
/// Prefer explicit `artist`, then `channel`, then `uploader`, then `creator`
|
||||||
|
/// — falls back to an empty string so the CLI formatter doesn't have to
|
||||||
|
/// special-case missing data.
|
||||||
|
fn pick_uploader(v: &serde_json::Value) -> String {
|
||||||
|
for key in ["artist", "channel", "uploader", "creator"] {
|
||||||
|
if let Some(s) = v.get(key).and_then(|x| x.as_str()) {
|
||||||
|
let s = s.trim();
|
||||||
|
if !s.is_empty() {
|
||||||
|
return s.to_string();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
String::new()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pick a thumbnail URL for a yt-dlp JSON line. Prefers the largest entry in
|
||||||
|
/// the `thumbnails[]` array (when yt-dlp emits per-resolution variants);
|
||||||
|
/// falls back to the singular `thumbnail` field. `None` is returned when
|
||||||
|
/// yt-dlp shipped no cover art at all (rare, but possible for short-lived
|
||||||
|
/// uploads or age-restricted items).
|
||||||
|
fn pick_thumbnail(v: &serde_json::Value) -> Option<String> {
|
||||||
|
if let Some(arr) = v.get("thumbnails").and_then(|x| x.as_array()) {
|
||||||
|
let mut best: Option<(i64, &str)> = None;
|
||||||
|
for entry in arr {
|
||||||
|
let url = entry.get("url").and_then(|x| x.as_str());
|
||||||
|
let h = entry.get("height").and_then(|x| x.as_i64()).unwrap_or(0);
|
||||||
|
if let Some(u) = url {
|
||||||
|
match best {
|
||||||
|
Some((bh, _)) if bh >= h => {}
|
||||||
|
_ => best = Some((h, u)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some((_, url)) = best {
|
||||||
|
return Some(url.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
v.get("thumbnail")
|
||||||
|
.and_then(|x| x.as_str())
|
||||||
|
.map(|s| s.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
/// Spawn a worker thread that downloads a song and updates the matching
|
/// Spawn a worker thread that downloads a song and updates the matching
|
||||||
/// `DownloadJob`. Returns the assigned job id synchronously.
|
/// `DownloadJob`. Returns the assigned job id synchronously.
|
||||||
pub fn init_async(state: &Arc<Mutex<DaemonState>>, name: String) -> i64 {
|
///
|
||||||
|
/// `name` is the display/storage name under which the song ends up in the
|
||||||
|
/// library. `source` is the literal argument to pass to `yt-dlp` — either an
|
||||||
|
/// explicit URL the user picked from `/search/yt` or `ytsearch1:NAME` for the
|
||||||
|
/// legacy auto-pick path.
|
||||||
|
pub fn init_async(state: &Arc<Mutex<DaemonState>>, name: String, source: String) -> i64 {
|
||||||
let job_id = state
|
let job_id = state
|
||||||
.lock()
|
.lock()
|
||||||
.unwrap()
|
.unwrap()
|
||||||
@@ -1004,6 +1154,7 @@ pub fn init_async(state: &Arc<Mutex<DaemonState>>, name: String) -> i64 {
|
|||||||
error: None,
|
error: None,
|
||||||
started_at: now,
|
started_at: now,
|
||||||
finished_at: None,
|
finished_at: None,
|
||||||
|
source: Some(source.clone()),
|
||||||
};
|
};
|
||||||
// Scope the locks so both MutexGuards drop together at the end of the
|
// Scope the locks so both MutexGuards drop together at the end of the
|
||||||
// block. A temporary `state.lock()` at statement boundary would be
|
// block. A temporary `state.lock()` at statement boundary would be
|
||||||
@@ -1019,7 +1170,9 @@ pub fn init_async(state: &Arc<Mutex<DaemonState>>, name: String) -> i64 {
|
|||||||
let arc = Arc::clone(state);
|
let arc = Arc::clone(state);
|
||||||
std::thread::Builder::new()
|
std::thread::Builder::new()
|
||||||
.name(format!("sjnmusic-dl-{job_id}"))
|
.name(format!("sjnmusic-dl-{job_id}"))
|
||||||
.spawn(move || run_download_job(arc, job_id, name_for_worker))
|
.spawn(move || {
|
||||||
|
run_download_job(arc, job_id, name_for_worker, source);
|
||||||
|
})
|
||||||
.expect("failed to spawn download worker");
|
.expect("failed to spawn download worker");
|
||||||
debug!("Spawned download job {job_id} for {name}");
|
debug!("Spawned download job {job_id} for {name}");
|
||||||
job_id
|
job_id
|
||||||
@@ -1027,10 +1180,21 @@ pub fn init_async(state: &Arc<Mutex<DaemonState>>, name: String) -> i64 {
|
|||||||
|
|
||||||
/// Spawn one worker per name. Returns the assigned ids in the same order.
|
/// Spawn one worker per name. Returns the assigned ids in the same order.
|
||||||
pub fn init_batch(state: &Arc<Mutex<DaemonState>>, names: Vec<String>) -> Vec<i64> {
|
pub fn init_batch(state: &Arc<Mutex<DaemonState>>, names: Vec<String>) -> Vec<i64> {
|
||||||
names.iter().map(|n| init_async(state, n.clone())).collect()
|
names
|
||||||
|
.iter()
|
||||||
|
.map(|n| {
|
||||||
|
let source = format!("ytsearch1:{n}");
|
||||||
|
init_async(state, n.clone(), source)
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn run_download_job(state: Arc<Mutex<DaemonState>>, job_id: i64, name: String) {
|
fn run_download_job(
|
||||||
|
state: Arc<Mutex<DaemonState>>,
|
||||||
|
job_id: i64,
|
||||||
|
name: String,
|
||||||
|
source: String,
|
||||||
|
) {
|
||||||
{
|
{
|
||||||
let guard = state.lock().unwrap();
|
let guard = state.lock().unwrap();
|
||||||
let mut jobs = guard.download_jobs.lock().unwrap();
|
let mut jobs = guard.download_jobs.lock().unwrap();
|
||||||
@@ -1038,7 +1202,7 @@ fn run_download_job(state: Arc<Mutex<DaemonState>>, job_id: i64, name: String) {
|
|||||||
j.status = "running".to_string();
|
j.status = "running".to_string();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let result = state.lock().unwrap().init_sync(&name);
|
let result = state.lock().unwrap().init_sync(&name, &source);
|
||||||
{
|
{
|
||||||
let guard = state.lock().unwrap();
|
let guard = state.lock().unwrap();
|
||||||
let mut jobs = guard.download_jobs.lock().unwrap();
|
let mut jobs = guard.download_jobs.lock().unwrap();
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
out/
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
|
.electron-builder-cache/
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
# sjnmusic-gui
|
||||||
|
|
||||||
|
Electron-based remote control for `sjnmusicd`. Pure HTTP client — the daemon
|
||||||
|
owns the audio engine, this GUI just renders state and fires commands.
|
||||||
|
|
||||||
|
## Run from source
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd gui
|
||||||
|
npm install
|
||||||
|
npm start
|
||||||
|
```
|
||||||
|
|
||||||
|
## Build distributables (Linux)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
npm run build:linux # AppImage + .deb in gui/out/
|
||||||
|
```
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
gui/src/
|
||||||
|
main/ Electron main process (BrowserWindow, polling, tray)
|
||||||
|
preload/ contextBridge IPC → window.api.{get,post,patch,put,del,onTick}
|
||||||
|
renderer/ Web Components (no framework, hand-rolled CSS)
|
||||||
|
```
|
||||||
|
|
||||||
|
The daemon stays untouched; the only additions are:
|
||||||
|
- `YtCandidate.thumbnail: Option<String>` (for picker cover art),
|
||||||
|
- `daemon/src/mpris.rs` registering `org.mpris.MediaPlayer2.sjnmusic` on the
|
||||||
|
session bus so media keys work without the GUI open.
|
||||||
|
|
||||||
|
State is polled from the main process at 1 Hz and pushed into the renderer
|
||||||
|
via `webContents.send('tick', …)`.
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
appId: dev.serverjonas.sjnmusic-gui
|
||||||
|
productName: sjnmusic
|
||||||
|
files:
|
||||||
|
- src/**/*
|
||||||
|
- package.json
|
||||||
|
asar: true
|
||||||
|
directories:
|
||||||
|
output: out
|
||||||
|
linux:
|
||||||
|
target:
|
||||||
|
- AppImage
|
||||||
|
- deb
|
||||||
|
category: AudioVideo
|
||||||
|
maintainer: serverjonas
|
||||||
|
desktop:
|
||||||
|
entry:
|
||||||
|
Name: sjnmusic
|
||||||
|
Comment: GUI for sjnmusicd
|
||||||
|
Categories: AudioVideo;Audio;Player;
|
||||||
|
Keywords: audio;music;mp3;daemon;
|
||||||
Generated
+5541
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,34 @@
|
|||||||
|
{
|
||||||
|
"name": "sjnmusic-gui",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "Electron-based GUI for sjnmusicd. Pure HTTP client; owns no audio state.",
|
||||||
|
"main": "src/main/index.mjs",
|
||||||
|
"type": "module",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"start": "electron .",
|
||||||
|
"dev": "electron . --enable-logging",
|
||||||
|
"build": "electron-builder",
|
||||||
|
"build:linux": "electron-builder --linux AppImage deb"
|
||||||
|
},
|
||||||
|
"build": {
|
||||||
|
"appId": "dev.serverjonas.sjnmusic-gui",
|
||||||
|
"productName": "sjnmusic",
|
||||||
|
"files": [
|
||||||
|
"src/**/*",
|
||||||
|
"package.json"
|
||||||
|
],
|
||||||
|
"linux": {
|
||||||
|
"target": ["AppImage", "deb"],
|
||||||
|
"category": "AudioVideo",
|
||||||
|
"maintainer": "serverjonas"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"electron-store": "^10.0.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"electron": "^33.0.2",
|
||||||
|
"electron-builder": "^25.1.8"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
// Thin HTTP client to sjnmusicd. Single object so the main process holds
|
||||||
|
// exactly one socket pool; retries the /now-playing poll on transient
|
||||||
|
// failures but does NOT crash the renderer when the daemon is offline.
|
||||||
|
|
||||||
|
const DEFAULT_HOST = '127.0.0.1';
|
||||||
|
const DEFAULT_PORT = 14567;
|
||||||
|
|
||||||
|
export class DaemonClient {
|
||||||
|
constructor({ host = DEFAULT_HOST, port = DEFAULT_PORT, timeoutMs = 8000 } = {}) {
|
||||||
|
this.base = `http://${host}:${port}`;
|
||||||
|
this.timeoutMs = timeoutMs;
|
||||||
|
}
|
||||||
|
|
||||||
|
async _req(method, path, body) {
|
||||||
|
const url = this.base + path;
|
||||||
|
const opts = { method, signal: AbortSignal.timeout(this.timeoutMs) };
|
||||||
|
if (body !== undefined) {
|
||||||
|
opts.headers = { 'Content-Type': 'application/json' };
|
||||||
|
opts.body = JSON.stringify(body);
|
||||||
|
}
|
||||||
|
let res;
|
||||||
|
try {
|
||||||
|
res = await fetch(url, opts);
|
||||||
|
} catch (e) {
|
||||||
|
const err = new Error(`daemon unreachable at ${url}: ${e.message}`);
|
||||||
|
err.causedByFetch = true;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
const text = await res.text();
|
||||||
|
let body_out = {};
|
||||||
|
if (text) {
|
||||||
|
try { body_out = JSON.parse(text); }
|
||||||
|
catch { body_out = { _raw: text }; }
|
||||||
|
}
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = new Error(body_out.error || `HTTP ${res.status}`);
|
||||||
|
err.status = res.status;
|
||||||
|
err.daemonResponse = body_out;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
return body_out;
|
||||||
|
}
|
||||||
|
|
||||||
|
get(path, query) {
|
||||||
|
let qs = '';
|
||||||
|
if (query && Object.keys(query).length > 0) {
|
||||||
|
qs = '?' + new URLSearchParams(query).toString();
|
||||||
|
}
|
||||||
|
return this._req('GET', path + qs);
|
||||||
|
}
|
||||||
|
|
||||||
|
post(path, body) { return this._req('POST', path, body); }
|
||||||
|
patch(path, body) { return this._req('PATCH', path, body); }
|
||||||
|
put(path, body) { return this._req('PUT', path, body); }
|
||||||
|
del(path, body) { return this._req('DELETE', path, body); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convenience: turn a "song name" object into what /play /add /del /search
|
||||||
|
* expect — lets render and main both speak the same JSON convention.
|
||||||
|
*/
|
||||||
|
export function searchBody(query) {
|
||||||
|
return { query };
|
||||||
|
}
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
// Electron main entrypoint. Owns the DaemonClient, polls /now-playing +
|
||||||
|
// /queue + /downloads on a 1s timer, forwards the snapshot to the renderer.
|
||||||
|
// Also owns the optional system tray.
|
||||||
|
|
||||||
|
import { app, BrowserWindow, ipcMain, Menu } from 'electron';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
import { DaemonClient } from './daemon.mjs';
|
||||||
|
import { installHandlers } from './ipc.mjs';
|
||||||
|
import { buildTray } from './tray.mjs';
|
||||||
|
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
const PRELOAD = path.join(__dirname, '../preload/preload.mjs');
|
||||||
|
const RENDERER = path.join(__dirname, '../renderer/index.html');
|
||||||
|
|
||||||
|
const POLL_INTERVAL_MS = 1000;
|
||||||
|
|
||||||
|
// Single-instance lock so launching `sjnmusic-gui` twice focuses the
|
||||||
|
// existing window instead of starting a second Chromium process.
|
||||||
|
const gotLock = app.requestSingleInstanceLock();
|
||||||
|
if (!gotLock) {
|
||||||
|
app.quit();
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
const daemon = new DaemonClient({
|
||||||
|
host: process.env.SJNMUSIC_HOST || '127.0.0.1',
|
||||||
|
port: parseInt(process.env.SJNMUSIC_PORT || '14567', 10),
|
||||||
|
});
|
||||||
|
|
||||||
|
let mainWindow = null;
|
||||||
|
let tray = null;
|
||||||
|
let pollTimer = null;
|
||||||
|
|
||||||
|
function emitTick(data) {
|
||||||
|
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||||
|
mainWindow.webContents.send('tick', data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pollOnce() {
|
||||||
|
try {
|
||||||
|
const [nowPlaying, queue, downloads] = await Promise.all([
|
||||||
|
daemon.get('/now-playing'),
|
||||||
|
daemon.get('/queue'),
|
||||||
|
daemon.get('/downloads'),
|
||||||
|
]);
|
||||||
|
emitTick({
|
||||||
|
online: true,
|
||||||
|
nowPlaying,
|
||||||
|
queue,
|
||||||
|
downloads,
|
||||||
|
polledAt: Date.now(),
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
emitTick({
|
||||||
|
online: false,
|
||||||
|
error: err.daemonResponse?.error || err.message,
|
||||||
|
polledAt: Date.now(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startPolling() {
|
||||||
|
// First poll immediately so the renderer doesn't sit on empty data.
|
||||||
|
pollOnce();
|
||||||
|
if (pollTimer) clearInterval(pollTimer);
|
||||||
|
pollTimer = setInterval(pollOnce, POLL_INTERVAL_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopPolling() {
|
||||||
|
if (pollTimer) {
|
||||||
|
clearInterval(pollTimer);
|
||||||
|
pollTimer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createMainWindow() {
|
||||||
|
mainWindow = new BrowserWindow({
|
||||||
|
width: 1180,
|
||||||
|
height: 760,
|
||||||
|
minWidth: 820,
|
||||||
|
minHeight: 520,
|
||||||
|
show: false,
|
||||||
|
backgroundColor: '#0f1115',
|
||||||
|
title: 'sjnmusic',
|
||||||
|
autoHideMenuBar: true,
|
||||||
|
webPreferences: {
|
||||||
|
preload: PRELOAD,
|
||||||
|
contextIsolation: true,
|
||||||
|
nodeIntegration: false,
|
||||||
|
sandbox: true,
|
||||||
|
spellcheck: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
Menu.setApplicationMenu(null);
|
||||||
|
mainWindow.removeMenu();
|
||||||
|
|
||||||
|
mainWindow.once('ready-to-show', () => mainWindow.show());
|
||||||
|
await mainWindow.loadFile(RENDERER);
|
||||||
|
}
|
||||||
|
|
||||||
|
app.on('second-instance', () => {
|
||||||
|
if (!mainWindow) return;
|
||||||
|
if (mainWindow.isMinimized()) mainWindow.restore();
|
||||||
|
mainWindow.focus();
|
||||||
|
});
|
||||||
|
|
||||||
|
app.whenReady().then(async () => {
|
||||||
|
installHandlers(ipcMain, daemon);
|
||||||
|
await createMainWindow();
|
||||||
|
tray = buildTray({ app, mainWindow: () => mainWindow, daemon });
|
||||||
|
startPolling();
|
||||||
|
|
||||||
|
app.on('activate', () => {
|
||||||
|
if (!mainWindow) createMainWindow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
app.on('window-all-closed', () => {
|
||||||
|
// On Linux + macOS we keep the process alive so the daemon polling
|
||||||
|
// continues; tray activation re-shows the window. On Windows we quit.
|
||||||
|
if (process.platform === 'win32' && !tray) {
|
||||||
|
app.quit();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.on('before-quit', () => {
|
||||||
|
stopPolling();
|
||||||
|
});
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
// IPC surface exposed to the renderer via preload. Each handler is a thin
|
||||||
|
// wrapper around the DaemonClient, so the renderer never holds a raw socket.
|
||||||
|
|
||||||
|
export function installHandlers(ipcMain, daemon) {
|
||||||
|
const wrap = (method) => async (_e, { path, body, query } = {}) => {
|
||||||
|
try {
|
||||||
|
return { ok: true, data: await daemon[method](path, query ?? body) };
|
||||||
|
} catch (err) {
|
||||||
|
const status = err.status || 0;
|
||||||
|
const causedByFetch = !!err.causedByFetch;
|
||||||
|
const msg = err.daemonResponse?.error || err.message;
|
||||||
|
return { ok: false, error: msg, status, causedByFetch };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
ipcMain.handle('daemon:get', wrap('get'));
|
||||||
|
ipcMain.handle('daemon:post', wrap('post'));
|
||||||
|
ipcMain.handle('daemon:patch', wrap('patch'));
|
||||||
|
ipcMain.handle('daemon:put', wrap('put'));
|
||||||
|
ipcMain.handle('daemon:del', wrap('del'));
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
// System tray icon. Built around Electron's `Tray` API. Some Linux setups
|
||||||
|
// (no libappindicator, Wayland without StatusNotifierItem) can refuse to
|
||||||
|
// construct a tray at all; we swallow that and return null so the rest of
|
||||||
|
// the app keeps running.
|
||||||
|
|
||||||
|
import { Menu, Tray, nativeImage } from 'electron';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Synthesize a tiny 16x16 dark theme icon in memory, since we don't ship
|
||||||
|
* image assets (and SVG rasterisation in Electron is unreliable). This
|
||||||
|
* gives a monochrome square that's pleasant enough until someone adds a
|
||||||
|
* real .png.
|
||||||
|
*/
|
||||||
|
function placeholderIcon() {
|
||||||
|
// 16x16 imageData: a teal play-triangle on a dark background.
|
||||||
|
const size = 16;
|
||||||
|
const buf = Buffer.alloc(size * size * 4);
|
||||||
|
for (let y = 0; y < size; y++) {
|
||||||
|
for (let x = 0; x < size; x++) {
|
||||||
|
const i = (y * size + x) * 4;
|
||||||
|
// Background: rgba(15,17,21,255)
|
||||||
|
let r = 15, g = 17, b = 21, a = 255;
|
||||||
|
// Triangle mask centred
|
||||||
|
const cx = x - 7;
|
||||||
|
const cy = y - 8;
|
||||||
|
const inside = Math.abs(cx) <= cy && cy >= -7 && cy <= 6;
|
||||||
|
if (inside) {
|
||||||
|
r = 108; g = 178; b = 255; // accent
|
||||||
|
}
|
||||||
|
buf[i] = r; buf[i + 1] = g; buf[i + 2] = b; buf[i + 3] = a;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nativeImage.createFromBitmap(buf, { width: size, height: size });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildTray({ app, mainWindow, daemon }) {
|
||||||
|
try {
|
||||||
|
const tray = new Tray(placeholderIcon());
|
||||||
|
tray.setToolTip('sjnmusic');
|
||||||
|
|
||||||
|
const rebuild = (state) => {
|
||||||
|
const win = mainWindow();
|
||||||
|
const showWin = () => win && !win.isDestroyed() && (win.isMinimized() ? win.restore() : win.show());
|
||||||
|
|
||||||
|
const menu = Menu.buildFromTemplate([
|
||||||
|
{ label: state?.nowPlaying?.current?.name || 'sjnmusic', enabled: false },
|
||||||
|
{ type: 'separator' },
|
||||||
|
{
|
||||||
|
label: state?.nowPlaying?.paused ? 'Resume' : 'Pause',
|
||||||
|
click: () => daemon.post(state?.nowPlaying?.paused ? '/resume' : '/pause'),
|
||||||
|
},
|
||||||
|
{ label: 'Skip', click: () => daemon.post('/skip') },
|
||||||
|
{ type: 'separator' },
|
||||||
|
{ label: 'Show window', click: showWin },
|
||||||
|
{ label: 'Quit', click: () => { app.isQuitting = true; app.quit(); } },
|
||||||
|
]);
|
||||||
|
tray.setContextMenu(menu);
|
||||||
|
};
|
||||||
|
|
||||||
|
rebuild({});
|
||||||
|
|
||||||
|
// We poll the daemon for state snapshot inside main/index.mjs's loop
|
||||||
|
// and forward via webContents. We mirror a tiny lastState here so the
|
||||||
|
// menu reflects pause/resume. Index.mjs calls tray.maybeUpdate(state)
|
||||||
|
// via the 'tick' channel — but since tray lives in main and we don't
|
||||||
|
// want to give it the BrowserWindow, we just hook IPC internally.
|
||||||
|
tray.maybeUpdate = rebuild;
|
||||||
|
return tray;
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('tray: failed to construct, continuing without:', e?.message || e);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
// preload: exposes a *narrow* IPC surface to the renderer.
|
||||||
|
// The renderer never sees Node, fs, or any daemon URL.
|
||||||
|
import { contextBridge, ipcRenderer } from 'electron';
|
||||||
|
|
||||||
|
// We accept both shapes from the renderer:
|
||||||
|
// (a) flat options: window.api.get('/foo', { q: 'x' })
|
||||||
|
// (b) wrapped: window.api.post('/foo', { body: {...} })
|
||||||
|
// window.api.get('/foo', { query: {...} })
|
||||||
|
// Whichever is provided wins; otherwise the second arg is routed as body
|
||||||
|
// (for POST/PATCH/PUT) or query (for GET/DELETE).
|
||||||
|
function _invoke(channel, kind) {
|
||||||
|
return async (path, opts = {}) => {
|
||||||
|
const body = kind === 'body' ? (opts.body ?? opts) : undefined;
|
||||||
|
const query = kind === 'query' ? (opts.query ?? opts) : undefined;
|
||||||
|
return ipcRenderer.invoke(channel, { path, body, query });
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const tickListeners = new Set();
|
||||||
|
ipcRenderer.on('tick', (_ev, data) => {
|
||||||
|
for (const cb of tickListeners) {
|
||||||
|
try { cb(data); } catch { /* never let one bad subscriber kill the bus */ }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
contextBridge.exposeInMainWorld('api', {
|
||||||
|
get: _invoke('daemon:get', 'query'),
|
||||||
|
post: _invoke('daemon:post', 'body'),
|
||||||
|
patch: _invoke('daemon:patch', 'body'),
|
||||||
|
put: _invoke('daemon:put', 'body'),
|
||||||
|
del: _invoke('daemon:del', 'query'),
|
||||||
|
|
||||||
|
onTick(callback) {
|
||||||
|
tickListeners.add(callback);
|
||||||
|
return () => tickListeners.delete(callback);
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
// Renderer entry point. Registers every Web Component, subscribes to
|
||||||
|
// `window.api.onTick`, and wires the hash-based router.
|
||||||
|
|
||||||
|
import { store } from './store.mjs';
|
||||||
|
import { applyTheme } from './theme.mjs';
|
||||||
|
|
||||||
|
import { register as regSidebar } from './components/sidebar.mjs';
|
||||||
|
import { register as regNowPlaying } from './components/now-playing.mjs';
|
||||||
|
import { register as regLibrary } from './components/library-view.mjs';
|
||||||
|
import { register as regQueueV } from './components/queue-view.mjs';
|
||||||
|
import { register as regPlaylistList } from './components/playlist-list-view.mjs';
|
||||||
|
import { register as regPlaylistV } from './components/playlist-view.mjs';
|
||||||
|
import { register as regSelnDl } from './components/search-download-view.mjs';
|
||||||
|
import { register as regDownloads } from './components/downloads-view.mjs';
|
||||||
|
import { register as regHistory } from './components/history-view.mjs';
|
||||||
|
import { register as regStats } from './components/stats-view.mjs';
|
||||||
|
import { register as regSettings } from './components/settings-view.mjs';
|
||||||
|
import { register as regToast } from './components/toast.mjs';
|
||||||
|
|
||||||
|
// 1. Apply persisted theme before any component renders, so the first
|
||||||
|
// paint doesn't flash the wrong colour.
|
||||||
|
applyTheme(store.state.theme);
|
||||||
|
|
||||||
|
// 2. Register all custom elements.
|
||||||
|
regSidebar();
|
||||||
|
regNowPlaying();
|
||||||
|
regLibrary();
|
||||||
|
regQueueV();
|
||||||
|
regPlaylistList();
|
||||||
|
regPlaylistV();
|
||||||
|
regSelnDl();
|
||||||
|
regDownloads();
|
||||||
|
regHistory();
|
||||||
|
regStats();
|
||||||
|
regSettings();
|
||||||
|
regToast();
|
||||||
|
|
||||||
|
// 3. Forward the polling ticks from main into the shared store.
|
||||||
|
window.api.onTick((snapshot) => {
|
||||||
|
store.set({
|
||||||
|
online: snapshot.online,
|
||||||
|
nowPlaying: snapshot.nowPlaying,
|
||||||
|
queue: snapshot.queue,
|
||||||
|
downloads: snapshot.downloads,
|
||||||
|
error: snapshot.error || null,
|
||||||
|
polledAt: snapshot.polledAt,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// 4. Minimal hash-based router. Recognised routes:
|
||||||
|
// #library, #queue, #playlists, #playlist/<name>, #search,
|
||||||
|
// #downloads, #history, #stats, #settings
|
||||||
|
function render() {
|
||||||
|
const hash = location.hash || '#library';
|
||||||
|
store.set({ route: hash });
|
||||||
|
const main = document.getElementById('main-pane');
|
||||||
|
if (!main) return;
|
||||||
|
main.innerHTML = '';
|
||||||
|
const [name, arg] = hash.replace(/^#/, '').split('/');
|
||||||
|
switch (name) {
|
||||||
|
case 'queue':
|
||||||
|
main.appendChild(document.createElement('app-queue-view'));
|
||||||
|
break;
|
||||||
|
case 'playlists':
|
||||||
|
main.appendChild(document.createElement('app-playlist-list-view'));
|
||||||
|
break;
|
||||||
|
case 'playlist':
|
||||||
|
const el = document.createElement('app-playlist-view');
|
||||||
|
if (arg) el.setAttribute('name', decodeURIComponent(arg));
|
||||||
|
main.appendChild(el);
|
||||||
|
break;
|
||||||
|
case 'search':
|
||||||
|
main.appendChild(document.createElement('app-search-download-view'));
|
||||||
|
break;
|
||||||
|
case 'downloads':
|
||||||
|
main.appendChild(document.createElement('app-downloads-view'));
|
||||||
|
break;
|
||||||
|
case 'history':
|
||||||
|
main.appendChild(document.createElement('app-history-view'));
|
||||||
|
break;
|
||||||
|
case 'stats':
|
||||||
|
main.appendChild(document.createElement('app-stats-view'));
|
||||||
|
break;
|
||||||
|
case 'settings':
|
||||||
|
main.appendChild(document.createElement('app-settings-view'));
|
||||||
|
break;
|
||||||
|
case 'library':
|
||||||
|
default:
|
||||||
|
main.appendChild(document.createElement('app-library-view'));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('hashchange', render);
|
||||||
|
render();
|
||||||
|
|
||||||
|
// 5. Keyboard shortcuts at the body level. We avoid stealing focus from
|
||||||
|
// text inputs by checking document.activeElement.
|
||||||
|
document.addEventListener('keydown', async (e) => {
|
||||||
|
const ae = document.activeElement;
|
||||||
|
if (ae && (ae.tagName === 'INPUT' || ae.tagName === 'TEXTAREA')) return;
|
||||||
|
if (e.code === 'Space') {
|
||||||
|
e.preventDefault();
|
||||||
|
await window.api.post('/pause').catch(() => {});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Inform the daemon we're alive — the daemon doesn't currently do anything
|
||||||
|
// with this; populate stats later if we add startup latency tracking.
|
||||||
|
window.api.get('/help').catch(() => {});
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
// Downloads view: live status table for in-flight yt-dlp jobs. Re-fetches
|
||||||
|
// every 3 seconds while this view is in the foreground (and immediately
|
||||||
|
// on every model tick from main, which is cheap enough).
|
||||||
|
|
||||||
|
import { store } from '../store.mjs';
|
||||||
|
|
||||||
|
export function register() {
|
||||||
|
if (customElements.get('app-downloads-view')) return;
|
||||||
|
customElements.define('app-downloads-view', class extends HTMLElement {
|
||||||
|
connectedCallback() {
|
||||||
|
this.render();
|
||||||
|
this._timer = setInterval(() => this.render(), 4000);
|
||||||
|
}
|
||||||
|
disconnectedCallback() {
|
||||||
|
clearInterval(this._timer);
|
||||||
|
}
|
||||||
|
|
||||||
|
async render() {
|
||||||
|
// Prefer the snapshot pushed by main; fall back to a fresh fetch
|
||||||
|
// if it's missing.
|
||||||
|
let dl = store.state.downloads?.downloads;
|
||||||
|
if (!dl) {
|
||||||
|
const r = await window.api.get('/downloads');
|
||||||
|
dl = r.ok ? (r.data?.downloads || []) : [];
|
||||||
|
}
|
||||||
|
const rows = dl.map(d => `
|
||||||
|
<tr>
|
||||||
|
<td>${d.id ?? '-'}</td>
|
||||||
|
<td><span class="badge ${d.status}">${escapeHtml(d.status || '?')}</span></td>
|
||||||
|
<td>${escapeHtml(d.name || '')}</td>
|
||||||
|
<td class="meta">${escapeHtml(d.source || '')}</td>
|
||||||
|
<td class="meta">${d.song_id ? `song ${d.song_id}` : ''}</td>
|
||||||
|
<td class="meta">${d.error ? escapeHtml(d.error) : ''}</td>
|
||||||
|
</tr>
|
||||||
|
`).join('') || `<tr><td colspan="6" class="meta">No jobs.</td></tr>`;
|
||||||
|
this.innerHTML = `
|
||||||
|
<div class="view-header">
|
||||||
|
<h1>Downloads</h1>
|
||||||
|
<button data-act="refresh">Refresh now</button>
|
||||||
|
</div>
|
||||||
|
<table class="downloads-table" style="width:100%;border-collapse:collapse;">
|
||||||
|
<thead>
|
||||||
|
<tr><th>id</th><th>status</th><th>name</th><th>source</th><th>result</th><th>error</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>${rows}</tbody>
|
||||||
|
</table>
|
||||||
|
`;
|
||||||
|
this.querySelector('[data-act="refresh"]').addEventListener('click', () => this.render());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
function escapeHtml(s) { return String(s||'').replace(/[<>&"]/g, c => ({'<':'<','>':'>','&':'&','"':'"'}[c])); }
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
// History view: most-recent 100 entries.
|
||||||
|
|
||||||
|
export function register() {
|
||||||
|
if (customElements.get('app-history-view')) return;
|
||||||
|
customElements.define('app-history-view', class extends HTMLElement {
|
||||||
|
connectedCallback() { this.render(); }
|
||||||
|
async render() {
|
||||||
|
let hist = [];
|
||||||
|
try {
|
||||||
|
const r = await window.api.get('/history', { limit: 100 });
|
||||||
|
hist = r.ok ? (r.data?.history || []) : [];
|
||||||
|
} catch (e) {}
|
||||||
|
const rows = hist.map(h => {
|
||||||
|
const date = new Date((h.played_at || 0) * 1000);
|
||||||
|
const when = isNaN(date.getTime()) ? '(no date)' : date.toLocaleString();
|
||||||
|
return `
|
||||||
|
<div class="row">
|
||||||
|
<span class="meta">${escapeHtml(when)}</span>
|
||||||
|
<div>
|
||||||
|
<div class="title">${escapeHtml(h.song_name || '(missing song)')}</div>
|
||||||
|
<div class="meta">id ${h.song_id}</div>
|
||||||
|
</div>
|
||||||
|
<span class="meta">${fmtSecs(h.duration_secs_played || 0)}</span>
|
||||||
|
<span></span>
|
||||||
|
</div>`;
|
||||||
|
}).join('') || `<div class="meta">No history yet.</div>`;
|
||||||
|
this.innerHTML = `
|
||||||
|
<div class="view-header"><h1>History</h1></div>
|
||||||
|
<div class="row-list">${rows}</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
function escapeHtml(s) { return String(s||'').replace(/[<>&"]/g, c => ({'<':'<','>':'>','&':'&','"':'"'}[c])); }
|
||||||
|
function fmtSecs(s) { s = Math.max(0, Math.round(s|0)); const m = Math.floor(s/60), r = s%60; return `${m}:${r.toString().padStart(2,'0')}`; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
// Library view: list of songs plus a fuzzy search box. Click to play.
|
||||||
|
|
||||||
|
import { store } from '../store.mjs';
|
||||||
|
|
||||||
|
export function register() {
|
||||||
|
if (customElements.get('app-library-view')) return;
|
||||||
|
customElements.define('app-library-view', class extends HTMLElement {
|
||||||
|
connectedCallback() { this.render(); }
|
||||||
|
render() {
|
||||||
|
this.innerHTML = `
|
||||||
|
<div class="view-header">
|
||||||
|
<h1>Library</h1>
|
||||||
|
<input class="view-search" type="search"
|
||||||
|
placeholder="Filter by name (fuzzy)"
|
||||||
|
${this.onsearch?.() ? '' : 'id="lib-q"'}>
|
||||||
|
</div>
|
||||||
|
<div id="lib-root" class="row-list"></div>
|
||||||
|
`;
|
||||||
|
const root = this.querySelector('#lib-root');
|
||||||
|
const qBox = this.querySelector('#lib-q');
|
||||||
|
const fetchOnce = async () => {
|
||||||
|
const q = qBox?.value?.trim() || '';
|
||||||
|
const r = q
|
||||||
|
? await window.api.get('/search', { q })
|
||||||
|
: await window.api.get('/songs');
|
||||||
|
const songs = q ? [r.data] : (r.data?.songs || []);
|
||||||
|
const items = songs
|
||||||
|
.filter(s => s && (s.id !== undefined))
|
||||||
|
.map(s => {
|
||||||
|
const dur = s.duration_secs ? ` · ${this._fmt(s.duration_secs)}` : '';
|
||||||
|
return `
|
||||||
|
<div class="row" data-id="${s.id}" data-name="${(s.name||'').replace(/"/g,'"')}">
|
||||||
|
<span class="meta">▶</span>
|
||||||
|
<div>
|
||||||
|
<div class="title">${(s.name||'').replace(/[<>&]/g, c => ({'<':'<','>':'>','&':'&'}[c]))}</div>
|
||||||
|
<div class="meta">id ${s.id}${dur}</div>
|
||||||
|
</div>
|
||||||
|
<button class="icon-btn" data-act="queue-add" title="Add to queue">+queue</button>
|
||||||
|
<button class="icon-btn danger" data-act="delete" title="Delete">delete</button>
|
||||||
|
</div>`;
|
||||||
|
}).join('') || `<div class="meta">No songs yet — head to Search + Download.</div>`;
|
||||||
|
root.innerHTML = items;
|
||||||
|
root.querySelectorAll('.row').forEach(el => {
|
||||||
|
el.addEventListener('click', (ev) => {
|
||||||
|
const tgt = ev.target;
|
||||||
|
if (tgt?.dataset?.act === 'queue-add') {
|
||||||
|
window.api.post('/add', { body: { query: el.dataset.name } });
|
||||||
|
} else if (tgt?.dataset?.act === 'delete') {
|
||||||
|
window.api.del('/del', { body: { query: el.dataset.name } });
|
||||||
|
} else {
|
||||||
|
window.api.post('/play', { body: { query: el.dataset.name } });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
qBox?.addEventListener('input', debounce(fetchOnce, 180));
|
||||||
|
fetchOnce();
|
||||||
|
}
|
||||||
|
_fmt(s) {
|
||||||
|
s = Math.max(0, Math.round(s|0));
|
||||||
|
const m = Math.floor(s/60), r = s % 60;
|
||||||
|
return `${m}:${r.toString().padStart(2,'0')}`;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function debounce(fn, ms) {
|
||||||
|
let t;
|
||||||
|
return (...args) => { clearTimeout(t); t = setTimeout(() => fn(...args), ms); };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
// Footer transport strip: title, transport buttons, volume slider, repeat
|
||||||
|
// selector. Re-renders on every tick.
|
||||||
|
|
||||||
|
import { store } from '../store.mjs';
|
||||||
|
import { fmtSecs } from '../fmt.mjs';
|
||||||
|
|
||||||
|
async function post(path, body) {
|
||||||
|
try {
|
||||||
|
const r = await window.api.post(path, body ? { body } : undefined);
|
||||||
|
if (!r.ok && r.error) console.warn(`POST ${path}`, r.error);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn(`POST ${path} failed`, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function register() {
|
||||||
|
if (customElements.get('app-now-playing')) return;
|
||||||
|
customElements.define('app-now-playing', class extends HTMLElement {
|
||||||
|
connectedCallback() {
|
||||||
|
this._unsub = store.subscribe(() => this.render());
|
||||||
|
this.render();
|
||||||
|
}
|
||||||
|
disconnectedCallback() {
|
||||||
|
this._unsub?.();
|
||||||
|
}
|
||||||
|
fmt(s) { return fmtSecs(s); }
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const np = store.state.nowPlaying || {};
|
||||||
|
const cur = np.current || {};
|
||||||
|
const title = cur.name || '(nothing playing)';
|
||||||
|
const elapsed = np.elapsed_secs ?? 0;
|
||||||
|
const raw_dur = np.duration_secs;
|
||||||
|
const duration = (raw_dur && raw_dur > 0) ? raw_dur : null;
|
||||||
|
const paused = !!np.paused;
|
||||||
|
const playing = !!np.playing;
|
||||||
|
const vol = np.volume ?? 1.0;
|
||||||
|
const repeat = np.repeat || 'off';
|
||||||
|
|
||||||
|
this.innerHTML = `
|
||||||
|
<div class="np-current">
|
||||||
|
${title.replace(/[<>&]/g, c => ({'<':'<','>':'>','&':'&'}[c]))}
|
||||||
|
<small>${paused ? 'paused' : (playing ? 'playing' : 'idle')}</small>
|
||||||
|
</div>
|
||||||
|
<div class="np-controls">
|
||||||
|
<button class="icon-btn" data-act="seek-back">⟲10s</button>
|
||||||
|
<button class="icon-btn" data-act="toggle">${paused || !playing ? '▶' : '⏸'}</button>
|
||||||
|
<button class="icon-btn" data-act="skip">⏭</button>
|
||||||
|
<select data-act="repeat" title="Repeat">
|
||||||
|
<option value="off" ${repeat==='off'?'selected':''}>off</option>
|
||||||
|
<option value="one" ${repeat==='one'?'selected':''}>one</option>
|
||||||
|
<option value="all" ${repeat==='all'?'selected':''}>all</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="np-progress">
|
||||||
|
<span class="np-time">${this.fmt(elapsed)} / ${this.fmt(duration)}</span>
|
||||||
|
<div class="np-volume">
|
||||||
|
<label>vol</label>
|
||||||
|
<input type="range" min="0" max="2" step="0.01" value="${vol}" data-act="volume">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
this.querySelector('[data-act="toggle"]')?.addEventListener('click',
|
||||||
|
() => paused ? post('/resume') : post('/pause'));
|
||||||
|
this.querySelector('[data-act="skip"]')?.addEventListener('click',
|
||||||
|
() => post('/skip'));
|
||||||
|
this.querySelector('[data-act="seek-back"]')?.addEventListener('click',
|
||||||
|
async () => {
|
||||||
|
const newPos = Math.max(0, (elapsed - 10));
|
||||||
|
await post('/seek', { secs: newPos });
|
||||||
|
});
|
||||||
|
this.querySelector('[data-act="repeat"]')?.addEventListener('change',
|
||||||
|
(e) => post('/repeat', { mode: e.target.value }));
|
||||||
|
this.querySelector('[data-act="volume"]')?.addEventListener('input',
|
||||||
|
(e) => post('/volume', { vol: parseFloat(e.target.value) }));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
// Playlist list view: shows all playlists; create via prompt.
|
||||||
|
|
||||||
|
export function register() {
|
||||||
|
if (customElements.get('app-playlist-list-view')) return;
|
||||||
|
customElements.define('app-playlist-list-view', class extends HTMLElement {
|
||||||
|
connectedCallback() { this.render(); }
|
||||||
|
async render() {
|
||||||
|
let playlists = [];
|
||||||
|
try {
|
||||||
|
const r = await window.api.get('/playlists');
|
||||||
|
playlists = r.ok ? (r.data || []) : [];
|
||||||
|
} catch (e) { playlists = []; }
|
||||||
|
const items = playlists.map(pl => `
|
||||||
|
<div class="row" data-name="${escapeHtml(pl.name)}">
|
||||||
|
<span class="meta">▶</span>
|
||||||
|
<div>
|
||||||
|
<div class="title">${escapeHtml(pl.name)}</div>
|
||||||
|
<div class="meta">${(pl.songs || []).length} songs</div>
|
||||||
|
</div>
|
||||||
|
<button class="icon-btn" data-act="go">open</button>
|
||||||
|
<button class="icon-btn danger" data-act="rm">delete</button>
|
||||||
|
</div>`).join('') || `<div class="meta">No playlists yet.</div>`;
|
||||||
|
this.innerHTML = `
|
||||||
|
<div class="view-header">
|
||||||
|
<h1>Playlists</h1>
|
||||||
|
<input id="pl-new" type="text" placeholder="new playlist name" style="flex:1;">
|
||||||
|
<button data-act="create">Create</button>
|
||||||
|
</div>
|
||||||
|
<div class="row-list">${items}</div>
|
||||||
|
`;
|
||||||
|
this.querySelector('[data-act="create"]')?.addEventListener('click', async () => {
|
||||||
|
const name = this.querySelector('#pl-new')?.value?.trim();
|
||||||
|
if (!name) return;
|
||||||
|
const r = await window.api.post('/playlists', { body: { name } });
|
||||||
|
if (r.ok) location.hash = `#playlist/${encodeURIComponent(name)}`;
|
||||||
|
else console.warn('create playlist failed', r.error);
|
||||||
|
});
|
||||||
|
this.querySelectorAll('.row').forEach(el => {
|
||||||
|
el.addEventListener('click', (ev) => {
|
||||||
|
const act = ev.target?.dataset?.act;
|
||||||
|
if (act === 'go') location.hash = `#playlist/${encodeURIComponent(el.dataset.name)}`;
|
||||||
|
else if (act === 'rm') window.api.del(`/playlists/${encodeURIComponent(el.dataset.name)}`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
function escapeHtml(s) { return String(s||'').replace(/[<>&"]/g, c => ({'<':'<','>':'>','&':'&','"':'"'}[c])); }
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
// Single playlist view: songs in a list, drag-reorder in v1.5, with
|
||||||
|
// up/down buttons for now. Rename + duplicate via header buttons.
|
||||||
|
|
||||||
|
export function register() {
|
||||||
|
if (customElements.get('app-playlist-view')) return;
|
||||||
|
customElements.define('app-playlist-view', class extends HTMLElement {
|
||||||
|
static get observedAttributes() { return ['name']; }
|
||||||
|
|
||||||
|
async connectedCallback() { this.render(); }
|
||||||
|
async attributeChangedCallback() { this.render(); }
|
||||||
|
|
||||||
|
async render() {
|
||||||
|
const name = this.getAttribute('name');
|
||||||
|
if (!name) {
|
||||||
|
this.innerHTML = `<div class="view-header"><h1>Playlist</h1></div>
|
||||||
|
<div class="meta">No playlist selected.</div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let pl = null;
|
||||||
|
try {
|
||||||
|
const r = await window.api.get(`/playlists/${encodeURIComponent(name)}`);
|
||||||
|
if (r.ok) pl = r.data;
|
||||||
|
} catch (e) { pl = null; }
|
||||||
|
const songs = pl?.songs || [];
|
||||||
|
const items = songs.map((s, i) => `
|
||||||
|
<div class="row" data-id="${s.id}" data-name="${escapeHtml(s.name)}" data-idx="${i}">
|
||||||
|
<span class="meta">${i + 1}</span>
|
||||||
|
<div>
|
||||||
|
<div class="title">${escapeHtml(s.name)}</div>
|
||||||
|
<div class="meta">id ${s.id}</div>
|
||||||
|
</div>
|
||||||
|
<button class="icon-btn" data-act="up" title="Move up">↑</button>
|
||||||
|
<button class="icon-btn" data-act="dn" title="Move down">↓</button>
|
||||||
|
<button class="icon-btn danger" data-act="rm">remove</button>
|
||||||
|
</div>`).join('') || `<div class="meta">empty playlist</div>`;
|
||||||
|
this.innerHTML = `
|
||||||
|
<div class="view-header">
|
||||||
|
<h1>${escapeHtml(name)}</h1>
|
||||||
|
<input id="pl-add-q" type="search" placeholder="add a song by name…" style="flex:1;">
|
||||||
|
<button data-act="add">Add</button>
|
||||||
|
<button data-act="play">Play</button>
|
||||||
|
<button data-act="rename">Rename</button>
|
||||||
|
<button data-act="duplicate">Duplicate</button>
|
||||||
|
<button data-act="delete" class="danger">Delete</button>
|
||||||
|
</div>
|
||||||
|
<div class="row-list">${items}</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const call = (method, path, body) => window.api[method](path, body ? { body } : undefined);
|
||||||
|
|
||||||
|
this.querySelector('[data-act="add"]')?.addEventListener('click', async () => {
|
||||||
|
const q = this.querySelector('#pl-add-q')?.value?.trim();
|
||||||
|
if (!q) return;
|
||||||
|
await call('post', `/playlists/${encodeURIComponent(name)}/add`, { query: q });
|
||||||
|
this.render();
|
||||||
|
});
|
||||||
|
this.querySelector('[data-act="play"]')?.addEventListener('click',
|
||||||
|
() => call('post', `/playlists/${encodeURIComponent(name)}/play`));
|
||||||
|
this.querySelector('[data-act="delete"]')?.addEventListener('click', async () => {
|
||||||
|
if (!confirm(`Delete playlist "${name}"?`)) return;
|
||||||
|
await call('del', `/playlists/${encodeURIComponent(name)}`);
|
||||||
|
location.hash = '#playlists';
|
||||||
|
});
|
||||||
|
this.querySelector('[data-act="rename"]')?.addEventListener('click', async () => {
|
||||||
|
const new_name = prompt('New name:', name);
|
||||||
|
if (!new_name || new_name === name) return;
|
||||||
|
await call('patch', `/playlists/${encodeURIComponent(name)}`, { name: new_name });
|
||||||
|
location.hash = `#playlist/${encodeURIComponent(new_name)}`;
|
||||||
|
});
|
||||||
|
this.querySelector('[data-act="duplicate"]')?.addEventListener('click', async () => {
|
||||||
|
const new_name = prompt('Duplicate as:', `${name} (copy)`);
|
||||||
|
if (!new_name) return;
|
||||||
|
await call('post', `/playlists/${encodeURIComponent(name)}/duplicate`, { name: new_name });
|
||||||
|
location.hash = `#playlist/${encodeURIComponent(new_name)}`;
|
||||||
|
});
|
||||||
|
this.querySelectorAll('.row').forEach(el => {
|
||||||
|
el.addEventListener('click', async (ev) => {
|
||||||
|
const act = ev.target?.dataset?.act;
|
||||||
|
if (act === 'rm') {
|
||||||
|
await call('del', `/playlists/${encodeURIComponent(name)}/songs/${el.dataset.id}`);
|
||||||
|
this.render();
|
||||||
|
} else if (act === 'up' || act === 'dn') {
|
||||||
|
const from = parseInt(el.dataset.idx, 10) + 1;
|
||||||
|
const to = from + (act === 'up' ? -1 : 1);
|
||||||
|
if (to < 1 || to > songs.length) return;
|
||||||
|
await call('post', `/playlists/${encodeURIComponent(name)}/reorder`, { from, to });
|
||||||
|
this.render();
|
||||||
|
} else {
|
||||||
|
window.api.post('/play', { body: { query: el.dataset.name } });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
function escapeHtml(s) { return String(s||'').replace(/[<>&"]/g, c => ({'<':'<','>':'>','&':'&','"':'"'}[c])); }
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
// Queue view: shows the in-memory queue with shuffle/clear actions.
|
||||||
|
// Drag-reorder between rows is wired in v1.5; for now we expose up/down
|
||||||
|
// buttons.
|
||||||
|
|
||||||
|
import { store } from '../store.mjs';
|
||||||
|
|
||||||
|
export function register() {
|
||||||
|
if (customElements.get('app-queue-view')) return;
|
||||||
|
customElements.define('app-queue-view', class extends HTMLElement {
|
||||||
|
connectedCallback() { this.render(); }
|
||||||
|
|
||||||
|
async render() {
|
||||||
|
// Always fetch fresh — the queue is small (<1k entries typically).
|
||||||
|
let queue, current;
|
||||||
|
try {
|
||||||
|
const r = await window.api.get('/queue');
|
||||||
|
queue = r.ok ? r.data?.queue || [] : [];
|
||||||
|
current = r.ok ? r.data?.current || null : null;
|
||||||
|
} catch (e) {
|
||||||
|
queue = [];
|
||||||
|
current = null;
|
||||||
|
}
|
||||||
|
const head = current ? `
|
||||||
|
<div class="stats-card" style="margin-bottom:16px;">
|
||||||
|
<div class="label">Now playing</div>
|
||||||
|
<div class="value">${escapeHtml(current.name)}</div>
|
||||||
|
</div>` : '';
|
||||||
|
const list = queue.map((s, i) => `
|
||||||
|
<div class="row" data-id="${s.id}" data-name="${escapeHtml(s.name)}" data-idx="${i}">
|
||||||
|
<span class="meta">${i + 1}</span>
|
||||||
|
<div>
|
||||||
|
<div class="title">${escapeHtml(s.name)}</div>
|
||||||
|
<div class="meta">id ${s.id}</div>
|
||||||
|
</div>
|
||||||
|
<button class="icon-btn" data-act="skip-to">▶</button>
|
||||||
|
<button class="icon-btn danger" data-act="queue-del" title="Remove">remove</button>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
this.innerHTML = `
|
||||||
|
<div class="view-header">
|
||||||
|
<h1>Queue</h1>
|
||||||
|
<button data-act="shuffle">Shuffle</button>
|
||||||
|
<button data-act="clear" class="danger">Clear</button>
|
||||||
|
</div>
|
||||||
|
${head}
|
||||||
|
<div class="row-list">${list || `<div class="meta">queue empty</div>`}</div>
|
||||||
|
`;
|
||||||
|
this.querySelector('[data-act="shuffle"]')?.addEventListener('click',
|
||||||
|
() => window.api.post('/queue/shuffle', { body: {} }));
|
||||||
|
this.querySelector('[data-act="clear"]')?.addEventListener('click',
|
||||||
|
() => window.api.post('/queue/clear', { body: {} }));
|
||||||
|
this.querySelectorAll('.row').forEach(el => {
|
||||||
|
el.addEventListener('click', (ev) => {
|
||||||
|
const act = ev.target?.dataset?.act;
|
||||||
|
if (act === 'skip-to') {
|
||||||
|
// Play the row's name; daemon resolves best match.
|
||||||
|
window.api.post('/play', { body: { query: el.dataset.name } });
|
||||||
|
} else if (act === 'queue-del') {
|
||||||
|
// Daemon doesn't have a "remove from queue" endpoint; ask
|
||||||
|
// /search-all to find the id, then post /play updates the head.
|
||||||
|
// For v1, the user can /del from library which also clears
|
||||||
|
// queue entries (and /play again to skip).
|
||||||
|
window.api.del('/del', { body: { query: el.dataset.name } });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
function escapeHtml(s) {
|
||||||
|
return String(s || '').replace(/[<>&"]/g, c => ({ '<':'<','>':'>','&':'&','"':'"' }[c]));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
// Search + Download view: text box → /search/yt → numbered cards with
|
||||||
|
// thumbnails, click to download via /init.
|
||||||
|
|
||||||
|
export function register() {
|
||||||
|
if (customElements.get('app-search-download-view')) return;
|
||||||
|
customElements.define('app-search-download-view', class extends HTMLElement {
|
||||||
|
connectedCallback() { this.render(); }
|
||||||
|
|
||||||
|
async render() {
|
||||||
|
this.innerHTML = `
|
||||||
|
<div class="view-header">
|
||||||
|
<h1>Search + Download</h1>
|
||||||
|
<input id="sd-q" class="view-search" type="search"
|
||||||
|
placeholder="Search YouTube via yt-dlp…">
|
||||||
|
<input id="sd-n" type="number" min="1" max="20" value="3"
|
||||||
|
title="candidates to show (1-20)" style="width:70px;">
|
||||||
|
<button data-act="go">Search</button>
|
||||||
|
</div>
|
||||||
|
<div id="sd-results" class="search-results"></div>
|
||||||
|
`;
|
||||||
|
this.querySelector('[data-act="go"]').addEventListener('click', () => this.runSearch());
|
||||||
|
this.querySelector('#sd-q').addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Enter') this.runSearch();
|
||||||
|
});
|
||||||
|
this.querySelector('#sd-q').focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
async runSearch(reason) {
|
||||||
|
const q = this.querySelector('#sd-q').value.trim();
|
||||||
|
const n = parseInt(this.querySelector('#sd-n').value, 10) || 3;
|
||||||
|
const grid = this.querySelector('#sd-results');
|
||||||
|
if (!q) { grid.innerHTML = `<div class="meta">Type a query above.</div>`; return; }
|
||||||
|
grid.innerHTML = `<div class="meta">Searching yt-dlp…</div>`;
|
||||||
|
const r = await window.api.get('/search/yt', { q, limit: n });
|
||||||
|
if (!r.ok) { grid.innerHTML = `<div class="meta">error: ${escapeHtml(r.error || 'failed')}</div>`; return; }
|
||||||
|
const results = r.data?.results || [];
|
||||||
|
if (results.length === 0) {
|
||||||
|
grid.innerHTML = `<div class="meta">No results.</div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
grid.innerHTML = results.map((c, i) => `
|
||||||
|
<button class="candidate" data-i="${i}">
|
||||||
|
${c.thumbnail
|
||||||
|
? `<img class="thumb" src="${escapeAttr(c.thumbnail)}" referrerpolicy="no-referrer" alt="" loading="lazy">`
|
||||||
|
: `<span class="thumb" style="display:flex;align-items:center;justify-content:center;font-size:24px;">🎵</span>`}
|
||||||
|
<div class="info">
|
||||||
|
<div class="t">${escapeHtml(c.title)}</div>
|
||||||
|
<div class="u">${escapeHtml(c.uploader || '(unknown artist)')}</div>
|
||||||
|
<div class="d">${fmtSecs(c.duration_secs)}</div>
|
||||||
|
</div>
|
||||||
|
</button>`).join('');
|
||||||
|
grid.querySelectorAll('.candidate').forEach(el => {
|
||||||
|
el.addEventListener('click', async () => {
|
||||||
|
const i = parseInt(el.dataset.i, 10);
|
||||||
|
const c = results[i];
|
||||||
|
grid.innerHTML = `<div class="meta">Downloading…</div>`;
|
||||||
|
const dr = await window.api.post('/init', { body: { name: q, url: c.url } });
|
||||||
|
if (!dr.ok) {
|
||||||
|
grid.innerHTML = `<div class="meta">error: ${escapeHtml(dr.error || 'failed')}</div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
grid.innerHTML = `
|
||||||
|
<div class="stats-card">
|
||||||
|
<div class="label">queued</div>
|
||||||
|
<div class="value">job ${dr.data.job_id}</div>
|
||||||
|
<div class="meta">Watching Downloads for completion…</div>
|
||||||
|
</div>`;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
function escapeHtml(s) { return String(s||'').replace(/[<>&"]/g, c => ({'<':'<','>':'>','&':'&','"':'"'}[c])); }
|
||||||
|
function escapeAttr(s) { return String(s||'').replace(/"/g, '"'); }
|
||||||
|
function fmtSecs(s) {
|
||||||
|
s = Math.max(0, Math.round(s|0));
|
||||||
|
const m = Math.floor(s/60), r = s % 60;
|
||||||
|
return `${m}:${r.toString().padStart(2,'0')}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
// Settings view: ONLY the light/dark theme picker, as requested.
|
||||||
|
|
||||||
|
import { store } from '../store.mjs';
|
||||||
|
import { applyTheme, THEMES } from '../theme.mjs';
|
||||||
|
|
||||||
|
export function register() {
|
||||||
|
if (customElements.get('app-settings-view')) return;
|
||||||
|
customElements.define('app-settings-view', class extends HTMLElement {
|
||||||
|
connectedCallback() { this.render(); }
|
||||||
|
render() {
|
||||||
|
const buttons = THEMES.map(t => `
|
||||||
|
<button data-t="${t}" class="${store.state.theme === t ? 'active' : ''}">
|
||||||
|
${t === 'dark' ? '🌙 Dark' : '☀️ Light'}
|
||||||
|
</button>`).join('');
|
||||||
|
this.innerHTML = `
|
||||||
|
<div class="view-header">
|
||||||
|
<h1>Settings</h1>
|
||||||
|
</div>
|
||||||
|
<div class="stats-card">
|
||||||
|
<div class="label">Theme</div>
|
||||||
|
<div class="theme-picker" style="margin-top:8px;">${buttons}</div>
|
||||||
|
<p class="meta" style="margin-top:10px;">
|
||||||
|
Default is dark. Choice persists per-machine (localStorage).
|
||||||
|
The daemon is unchanged — the GUI reads its theme locally.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="stats-card" style="margin-top:16px;">
|
||||||
|
<div class="label">Daemon</div>
|
||||||
|
<p class="meta">The GUI is a remote control. It does not own audio.
|
||||||
|
Edit <code>~/.sjn/music/config.toml</code> and restart the daemon
|
||||||
|
to change host/port, library paths, fuzzy threshold, …</p>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
this.querySelectorAll('[data-t]').forEach(el => {
|
||||||
|
el.addEventListener('click', () => applyTheme(el.dataset.t));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
// Sidebar: navigation + offline banner. Re-renders whenever the store
|
||||||
|
// reports `online` toggling, since the banner belongs here.
|
||||||
|
|
||||||
|
import { store } from '../store.mjs';
|
||||||
|
|
||||||
|
const ROUTES = [
|
||||||
|
{ hash: '#library', label: 'Library', icon: '🎵' },
|
||||||
|
{ hash: '#queue', label: 'Queue', icon: '📜' },
|
||||||
|
{ hash: '#playlists', label: 'Playlists', icon: '➕' },
|
||||||
|
{ hash: '#search', label: 'Search + Download', icon: '🔎' },
|
||||||
|
{ hash: '#downloads', label: 'Downloads', icon: '⬇' },
|
||||||
|
{ hash: '#history', label: 'History', icon: '🕓' },
|
||||||
|
{ hash: '#stats', label: 'Stats', icon: '📊' },
|
||||||
|
{ hash: '#settings', label: 'Settings', icon: '⚙' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function register() {
|
||||||
|
if (customElements.get('app-sidebar')) return;
|
||||||
|
customElements.define('app-sidebar', class extends HTMLElement {
|
||||||
|
connectedCallback() {
|
||||||
|
this._unsub = store.subscribe(() => this.render());
|
||||||
|
this.render();
|
||||||
|
}
|
||||||
|
disconnectedCallback() {
|
||||||
|
this._unsub?.();
|
||||||
|
}
|
||||||
|
render() {
|
||||||
|
const { online, route, error } = store.state;
|
||||||
|
const navHtml = ROUTES.map(r => `
|
||||||
|
<a class="nav-link ${route === r.hash ? 'active' : ''}" href="${r.hash}">
|
||||||
|
<span aria-hidden="true">${r.icon}</span>
|
||||||
|
<span class="nav-label">${r.label}</span>
|
||||||
|
</a>
|
||||||
|
`).join('');
|
||||||
|
const banner = online ? '' :
|
||||||
|
`<div class="offline-banner" title="${(error || 'daemon unreachable').replace(/"/g, '"')}">
|
||||||
|
daemon offline
|
||||||
|
</div>`;
|
||||||
|
this.innerHTML = `
|
||||||
|
<div class="brand">sjnmusic</div>
|
||||||
|
${banner}
|
||||||
|
<nav>${navHtml}</nav>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
// Stats view: aggregate cards from /stats plus top songs.
|
||||||
|
|
||||||
|
import { fmtSecs, fmtBigSecs, escapeHtml } from '../fmt.mjs';
|
||||||
|
|
||||||
|
export function register() {
|
||||||
|
if (customElements.get('app-stats-view')) return;
|
||||||
|
customElements.define('app-stats-view', class extends HTMLElement {
|
||||||
|
connectedCallback() { this.render(); }
|
||||||
|
async render() {
|
||||||
|
let stats = { total_plays: 0, total_secs: 0, top_songs: [] };
|
||||||
|
try {
|
||||||
|
const r = await window.api.get('/stats');
|
||||||
|
if (r.ok) stats = r.data || stats;
|
||||||
|
} catch (e) {}
|
||||||
|
const cards = `
|
||||||
|
<div class="stats-grid">
|
||||||
|
<div class="stats-card">
|
||||||
|
<div class="label">Total plays</div>
|
||||||
|
<div class="value">${stats.total_plays || 0}</div>
|
||||||
|
</div>
|
||||||
|
<div class="stats-card">
|
||||||
|
<div class="label">Total time</div>
|
||||||
|
<div class="value">${fmtBigSecs(stats.total_secs || 0)}</div>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
const top = (stats.top_songs || []).map((t, i) => `
|
||||||
|
<div class="row">
|
||||||
|
<span class="meta">${i + 1}</span>
|
||||||
|
<div>
|
||||||
|
<div class="title">${escapeHtml(t.name || '(missing)')}</div>
|
||||||
|
<div class="meta">id ${t.song_id}</div>
|
||||||
|
</div>
|
||||||
|
<span class="meta">${t.plays} plays</span>
|
||||||
|
<span class="meta">${fmtSecs(t.total_secs || 0)}</span>
|
||||||
|
</div>`).join('') || `<div class="meta">No plays yet.</div>`;
|
||||||
|
this.innerHTML = `
|
||||||
|
<div class="view-header"><h1>Stats</h1></div>
|
||||||
|
${cards}
|
||||||
|
<h2 style="margin-top:20px;">Top songs</h2>
|
||||||
|
<div class="row-list">${top}</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
// Toast host: small notification banner overlay. Listens to store.toasts.
|
||||||
|
|
||||||
|
import { store } from '../store.mjs';
|
||||||
|
|
||||||
|
export function register() {
|
||||||
|
if (customElements.get('app-toast-host')) return;
|
||||||
|
customElements.define('app-toast-host', class extends HTMLElement {
|
||||||
|
connectedCallback() {
|
||||||
|
this._unsub = store.subscribe(() => this.render());
|
||||||
|
this.render();
|
||||||
|
}
|
||||||
|
disconnectedCallback() { this._unsub?.(); }
|
||||||
|
render() {
|
||||||
|
const items = (store.state.toasts || [])
|
||||||
|
.map(t => `<div class="toast ${t.level}">${escapeHtml(t.message)}</div>`)
|
||||||
|
.join('');
|
||||||
|
this.innerHTML = items || '';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
function escapeHtml(s) {
|
||||||
|
return String(s||'').replace(/[<>&"]/g, c => ({'<':'<','>':'>','&':'&','"':'"'}[c]));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
// Small formatting helpers shared across components.
|
||||||
|
|
||||||
|
export function fmtSecs(s) {
|
||||||
|
const v = Math.max(0, Math.round(Number(s) || 0));
|
||||||
|
const m = Math.floor(v / 60), r = v % 60;
|
||||||
|
return `${m}:${r.toString().padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fmtBigSecs(s) {
|
||||||
|
const v = Math.max(0, Math.round(Number(s) || 0));
|
||||||
|
const h = Math.floor(v / 3600);
|
||||||
|
const m = Math.floor((v % 3600) / 60);
|
||||||
|
const sec = v % 60;
|
||||||
|
if (h > 0) return `${h}h ${m}m`;
|
||||||
|
if (m > 0) return `${m}m ${sec}s`;
|
||||||
|
return `${sec}s`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function escapeHtml(s) {
|
||||||
|
return String(s == null ? '' : s).replace(/[<>&"]/g,
|
||||||
|
c => ({ '<': '<', '>': '>', '&': '&', '"': '"' }[c]));
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta http-equiv="Content-Security-Policy"
|
||||||
|
content="default-src 'self'; img-src 'self' http: https: data:; style-src 'self' 'unsafe-inline'; script-src 'self'">
|
||||||
|
<title>sjnmusic</title>
|
||||||
|
<link rel="stylesheet" href="styles.css">
|
||||||
|
</head>
|
||||||
|
<body data-theme="dark">
|
||||||
|
<div id="app" class="app-shell">
|
||||||
|
<app-sidebar class="sidebar"></app-sidebar>
|
||||||
|
<main id="main-pane" class="main-pane"></main>
|
||||||
|
<app-now-playing class="now-playing-footer"></app-now-playing>
|
||||||
|
<app-toast-host class="toast-host"></app-toast-host>
|
||||||
|
</div>
|
||||||
|
<script type="module" src="app.mjs"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
// Shared store: one mutable object + a subscribers list. The renderer
|
||||||
|
// registers components, and the polling loop calls `store.set(...)` which
|
||||||
|
// triggers a render for each subscriber.
|
||||||
|
|
||||||
|
const state = {
|
||||||
|
online: false,
|
||||||
|
nowPlaying: null,
|
||||||
|
queue: null,
|
||||||
|
downloads: null,
|
||||||
|
error: null,
|
||||||
|
route: '#library',
|
||||||
|
theme: localStorage.getItem('sjnmusic.theme') || 'dark',
|
||||||
|
toasts: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const subs = new Set();
|
||||||
|
|
||||||
|
export const store = {
|
||||||
|
state,
|
||||||
|
set(partial) {
|
||||||
|
Object.assign(state, partial);
|
||||||
|
for (const cb of subs) {
|
||||||
|
try { cb(state); } catch { /* defensive */ }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
subscribe(cb) {
|
||||||
|
subs.add(cb);
|
||||||
|
cb(state);
|
||||||
|
return () => subs.delete(cb);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export function pushToast(message, level = 'info', ttlMs = 3500) {
|
||||||
|
const id = Math.random().toString(36).slice(2);
|
||||||
|
state.toasts.push({ id, message, level });
|
||||||
|
store.set({ toasts: [...state.toasts] });
|
||||||
|
if (ttlMs > 0) {
|
||||||
|
setTimeout(() => {
|
||||||
|
state.toasts = state.toasts.filter(t => t.id !== id);
|
||||||
|
store.set({ toasts: [...state.toasts] });
|
||||||
|
}, ttlMs);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,263 @@
|
|||||||
|
/*
|
||||||
|
* sjnmusic GUI — hand-rolled CSS, dark theme default.
|
||||||
|
* Layout: grid with sidebar | main | footer-now-playing.
|
||||||
|
* Theme switching only flips CSS variables on `body[data-theme="..."]`.
|
||||||
|
*/
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--bg-primary: #0f1115;
|
||||||
|
--bg-secondary: #161a20;
|
||||||
|
--bg-tertiary: #1f242c;
|
||||||
|
--bg-elev: #232830;
|
||||||
|
--border: #2a2f38;
|
||||||
|
--border-strong: #393f4a;
|
||||||
|
--text-primary: #e6e6e6;
|
||||||
|
--text-secondary: #999;
|
||||||
|
--text-muted: #6c727c;
|
||||||
|
--accent: #6cb2ff;
|
||||||
|
--accent-hover: #87c0ff;
|
||||||
|
--accent-pressed: #5496e0;
|
||||||
|
--danger: #ff6b6b;
|
||||||
|
--success: #4ed98c;
|
||||||
|
--warning: #ffb84d;
|
||||||
|
--shadow: 0 6px 24px rgba(0,0,0,0.35);
|
||||||
|
--radius-sm: 4px;
|
||||||
|
--radius-md: 8px;
|
||||||
|
--radius-lg: 12px;
|
||||||
|
--sidebar-w: 220px;
|
||||||
|
--footer-h: 96px;
|
||||||
|
--row-pad-y: 4px;
|
||||||
|
--row-pad-x: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
body[data-theme="light"] {
|
||||||
|
--bg-primary: #ffffff;
|
||||||
|
--bg-secondary: #f5f6f8;
|
||||||
|
--bg-tertiary: #ebedf1;
|
||||||
|
--bg-elev: #ffffff;
|
||||||
|
--border: #d6dae0;
|
||||||
|
--border-strong: #b3b9c4;
|
||||||
|
--text-primary: #1a1a1a;
|
||||||
|
--text-secondary: #555;
|
||||||
|
--text-muted: #888;
|
||||||
|
--accent: #2076d4;
|
||||||
|
--accent-hover: #1a5faf;
|
||||||
|
--accent-pressed: #145099;
|
||||||
|
--danger: #c94545;
|
||||||
|
--success: #2baf6c;
|
||||||
|
--warning: #cb7a14;
|
||||||
|
--shadow: 0 4px 18px rgba(0,0,0,0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
html, body, #app { height: 100%; margin: 0; }
|
||||||
|
body {
|
||||||
|
font-family: 'Inter', system-ui, sans-serif;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.4;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
font: inherit;
|
||||||
|
color: inherit;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
padding: 6px 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.1s ease, border-color 0.1s ease;
|
||||||
|
}
|
||||||
|
button:hover:not(:disabled) { background: var(--bg-tertiary); border-color: var(--border-strong); }
|
||||||
|
button:active:not(:disabled) { background: var(--bg-elev); }
|
||||||
|
button.primary { background: var(--accent); border-color: var(--accent); color: #fff; }
|
||||||
|
button.primary:hover { background: var(--accent-hover); border-color: var(--accent-hover); }
|
||||||
|
button.danger { color: var(--danger); border-color: var(--border); }
|
||||||
|
button.danger:hover { background: var(--danger); color: #fff; border-color: var(--danger); }
|
||||||
|
button:disabled { opacity: 0.45; cursor: not-allowed; }
|
||||||
|
|
||||||
|
input[type="text"], input[type="search"], select, textarea {
|
||||||
|
font: inherit;
|
||||||
|
color: inherit;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
padding: 6px 8px;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
input:focus, select:focus, textarea:focus {
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* -------- shell layout -------- */
|
||||||
|
|
||||||
|
.app-shell {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: var(--sidebar-w) 1fr;
|
||||||
|
grid-template-rows: 1fr var(--footer-h);
|
||||||
|
height: 100vh;
|
||||||
|
}
|
||||||
|
.sidebar { grid-column: 1; grid-row: 1 / 3; background: var(--bg-secondary); border-right: 1px solid var(--border); }
|
||||||
|
.main-pane { grid-column: 2; grid-row: 1; overflow: auto; padding: 16px 24px; }
|
||||||
|
.now-playing-footer { grid-column: 2; grid-row: 2; background: var(--bg-secondary); border-top: 1px solid var(--border); }
|
||||||
|
.toast-host { position: fixed; bottom: 110px; right: 16px; z-index: 1000; }
|
||||||
|
|
||||||
|
/* -------- sidebar -------- */
|
||||||
|
|
||||||
|
.sidebar nav { padding: 12px 0; }
|
||||||
|
.sidebar .nav-link {
|
||||||
|
display: block;
|
||||||
|
padding: 8px 18px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
text-decoration: none;
|
||||||
|
border-left: 3px solid transparent;
|
||||||
|
transition: background-color 0.1s ease;
|
||||||
|
}
|
||||||
|
.sidebar .nav-link:hover { background: var(--bg-tertiary); color: var(--text-primary); }
|
||||||
|
.sidebar .nav-link.active { background: var(--bg-tertiary); color: var(--accent); border-left-color: var(--accent); }
|
||||||
|
.sidebar .brand {
|
||||||
|
padding: 16px 18px 24px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
.sidebar .offline-banner {
|
||||||
|
margin: 8px 12px;
|
||||||
|
padding: 6px 8px;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* -------- now-playing footer -------- */
|
||||||
|
|
||||||
|
.now-playing-footer {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr auto 1fr;
|
||||||
|
align-items: center;
|
||||||
|
gap: 24px;
|
||||||
|
padding: 8px 24px;
|
||||||
|
}
|
||||||
|
.np-current { font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.np-current small { display: block; font-weight: 400; color: var(--text-secondary); }
|
||||||
|
.np-controls { display: flex; gap: 6px; align-items: center; }
|
||||||
|
.np-progress { display: flex; gap: 8px; align-items: center; justify-content: flex-end; }
|
||||||
|
.np-progress input[type=range] { width: 220px; }
|
||||||
|
.np-time { width: 96px; font-variant-numeric: tabular-nums; color: var(--text-secondary); text-align: right; }
|
||||||
|
.np-volume input[type=range] { width: 90px; vertical-align: middle; }
|
||||||
|
.icon-btn { padding: 4px 8px; min-width: 32px; }
|
||||||
|
|
||||||
|
/* -------- generic list rows -------- */
|
||||||
|
|
||||||
|
.row-list { display: flex; flex-direction: column; gap: 1px; }
|
||||||
|
.row-list .row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 24px 1fr auto auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: var(--row-pad-y) var(--row-pad-x);
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
}
|
||||||
|
.row-list .row:hover { background: var(--bg-tertiary); }
|
||||||
|
.row .title { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.row .meta { color: var(--text-secondary); font-size: 12px; }
|
||||||
|
.row.drop-target { box-shadow: inset 0 2px 0 var(--accent); }
|
||||||
|
.row.dragging { opacity: 0.4; }
|
||||||
|
|
||||||
|
/* -------- view-specific -------- */
|
||||||
|
|
||||||
|
.view-header {
|
||||||
|
display: flex; align-items: center; gap: 12px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.view-header h1 { margin: 0; font-size: 22px; font-weight: 600; }
|
||||||
|
.view-search { flex: 1; }
|
||||||
|
|
||||||
|
.search-results {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
.candidate {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
padding: 12px;
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: flex-start;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 0.1s ease, transform 0.05s ease;
|
||||||
|
}
|
||||||
|
.candidate:hover { border-color: var(--accent); }
|
||||||
|
.candidate img.thumb {
|
||||||
|
width: 64px; height: 64px; object-fit: cover;
|
||||||
|
border-radius: var(--radius-sm); background: var(--bg-tertiary);
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
.candidate .info { flex: 1; min-width: 0; }
|
||||||
|
.candidate .info .t { font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.candidate .info .u { color: var(--text-secondary); font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.candidate .info .d { color: var(--text-muted); font-size: 11px; }
|
||||||
|
|
||||||
|
.downloads-table th { text-align: left; padding: 4px 8px; color: var(--text-secondary); font-weight: 500; font-size: 12px; border-bottom: 1px solid var(--border); }
|
||||||
|
.downloads-table td { padding: 6px 8px; border-bottom: 1px solid var(--border); }
|
||||||
|
.badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 1px 7px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 11px;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.badge.running { color: var(--accent); border-color: var(--accent); }
|
||||||
|
.badge.done { color: var(--success); border-color: var(--success); }
|
||||||
|
.badge.failed { color: var(--danger); border-color: var(--danger); }
|
||||||
|
|
||||||
|
.stats-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
.stats-card {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
.stats-card .label { color: var(--text-secondary); font-size: 12px; text-transform: uppercase; letter-spacing: 0.05em; }
|
||||||
|
.stats-card .value { font-size: 28px; font-weight: 600; margin-top: 4px; }
|
||||||
|
|
||||||
|
.theme-picker { display: flex; gap: 12px; }
|
||||||
|
.theme-picker button { padding: 6px 14px; }
|
||||||
|
.theme-picker button.active { background: var(--accent); color: #fff; border-color: var(--accent); }
|
||||||
|
|
||||||
|
/* toast */
|
||||||
|
.toast {
|
||||||
|
background: var(--bg-elev);
|
||||||
|
color: var(--text-primary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-left: 3px solid var(--accent);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
padding: 10px 14px;
|
||||||
|
margin-top: 8px;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
min-width: 200px;
|
||||||
|
max-width: 360px;
|
||||||
|
}
|
||||||
|
.toast.error { border-left-color: var(--danger); }
|
||||||
|
.toast.warn { border-left-color: var(--warning); }
|
||||||
|
|
||||||
|
/* responsive */
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
:root { --sidebar-w: 56px; }
|
||||||
|
.sidebar .nav-label { display: none; }
|
||||||
|
.now-playing-footer { grid-template-columns: 1fr; gap: 6px; }
|
||||||
|
.now-playing-footer .np-progress, .np-volume { justify-self: stretch; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
// Theme management. Only two values: 'dark' (default) and 'light'. We
|
||||||
|
// store the choice in localStorage AND sync to the daemon HTTP read at
|
||||||
|
// startup if the daemon is reachable (deferred for v1).
|
||||||
|
|
||||||
|
import { store } from './store.mjs';
|
||||||
|
|
||||||
|
export const THEMES = ['dark', 'light'];
|
||||||
|
|
||||||
|
export function applyTheme(name) {
|
||||||
|
if (!THEMES.includes(name)) name = 'dark';
|
||||||
|
document.body.dataset.theme = name;
|
||||||
|
store.set({ theme: name });
|
||||||
|
try { localStorage.setItem('sjnmusic.theme', name); } catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cycleTheme() {
|
||||||
|
applyTheme(store.state.theme === 'dark' ? 'light' : 'dark');
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user