mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 23:35:44 +00:00
Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 48d2cf2640 | |||
| ab344c7c23 | |||
| fe01fc8ddc | |||
| c966b9477e | |||
| 8cdb770250 | |||
| 817e3c4685 | |||
| 76d028127d | |||
| 4fd85f2dd4 | |||
| bca0acbaff |
@@ -15,11 +15,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
### Playback freeze on track changes
|
||||
|
||||
**By [@Psychotoxical](https://github.com/Psychotoxical)**
|
||||
|
||||
* Changing tracks — skipping, or the automatic advance at the end of a song — could freeze the interface for several seconds while audio kept playing (the progress bar and lyrics stopped updating). The queue header recomputed its duration totals on every track change instead of only when the queue itself changes; it now recomputes only on queue changes, so track changes stay instant.
|
||||
* This also resolves output-device changes not being applied on Windows: the same freeze was blocking playback from following the newly selected device.
|
||||
|
||||
### Paused or stopped playback restarting on headphone disconnect (macOS)
|
||||
|
||||
**By [@Psychotoxical](https://github.com/Psychotoxical)**
|
||||
|
||||
* On macOS, pausing or stopping playback and then disconnecting headphones (or otherwise switching the audio output device) could make playback restart on the newly selected device. Playback now reliably stays paused or stopped across a device change.
|
||||
|
||||
### Crash when seeking Opus/Ogg files
|
||||
@@ -28,14 +32,38 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
* Scrubbing the seekbar on an Opus/Ogg file — and then pressing Stop — crashed the whole app (a 1.48 regression from the Symphonia 0.6 migration). The Ogg demuxer recorded its seek bounds only when the source was seekable during the format probe, but probing hid seekability, so the first seek panicked on the audio thread (`Option::unwrap()` on `None`) and took the process down at the audio backend boundary.
|
||||
* Local and in-memory Opus/Ogg sources now stay seekable through the probe, so seeking works correctly. As a safety net, any decoder panic during a seek is contained instead of crashing the app; for Opus/Ogg streamed over HTTP, seeking is a no-op for now rather than a crash.
|
||||
|
||||
### Discord Rich Presence cover art missing with two server addresses
|
||||
|
||||
**By [@Psychotoxical](https://github.com/Psychotoxical)**
|
||||
|
||||
* When a server profile had both a local and a public address, Discord Rich Presence showed the placeholder icon instead of the album cover. The cover URL used the local address, which Discord's servers can't reach; it now uses the public address (the same one used for share links).
|
||||
|
||||
### "Minimize to Tray" ignored on the macOS close button
|
||||
|
||||
**By [@Psychotoxical](https://github.com/Psychotoxical)**
|
||||
|
||||
* On macOS, closing the window with the red close button always quit the app, even with "Minimize to Tray" enabled. The close button now respects the setting — with it on, the window hides to the tray instead of quitting, the same as the tray icon's "Hide".
|
||||
|
||||
### Library sync stalling for many seconds on large Navidrome collections
|
||||
|
||||
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1105](https://github.com/Psychotoxical/psysonic/pull/1105)**
|
||||
|
||||
* On large libraries (reported with ~200,000 tracks on Navidrome), background library sync could lock up database writes for minutes at a time — playback history, ratings and other saves piled up waiting behind it.
|
||||
* Root cause: the track id-remap step ran a database lookup that couldn't use its indexes and scanned the entire track table once per incoming track, so the cost grew with the square of the library size. The lookup now uses the proper indexes, bringing it back to a fast, near-instant operation.
|
||||
|
||||
### Album cover missing in Windows media controls
|
||||
|
||||
**By [@Psychotoxical](https://github.com/Psychotoxical)**
|
||||
|
||||
* On Windows, the system media controls (the Quick Settings media tile, the lock screen and third-party media flyouts) showed the track title and artist but no album cover. Windows could not decode the cached WebP cover art for its thumbnail, even with the Store WebP extension installed. The cover is now converted to PNG before it is handed to the media controls, so the artwork shows again. macOS and Linux are unaffected.
|
||||
|
||||
### Windows media controls showed "Unknown application"
|
||||
|
||||
**By [@Psychotoxical](https://github.com/Psychotoxical)**
|
||||
|
||||
* On Windows, the system media controls (the Quick Settings media tile, the lock screen and third-party media flyouts) labelled playback as "Unknown application" with no icon. The app now registers an explicit application identity at startup so Windows shows "Psysonic" and its icon as the playback source.
|
||||
|
||||
|
||||
|
||||
## [1.48.0]
|
||||
|
||||
+18
-1
@@ -11,7 +11,24 @@ Within each section, order by **user impact** (most noticeable first) — not PR
|
||||
|
||||
## Fixed
|
||||
|
||||
- Fixed an issue with Opus playback where seeking the seekbar could crash the app.
|
||||
### Playback and audio
|
||||
|
||||
- Changing tracks — skipping, or the automatic advance at the end of a song — no longer freezes the interface for a few seconds: the progress bar and lyrics keep updating, and on **Windows** a change of output device now takes effect right away.
|
||||
- Seeking an **Opus/Ogg** track — and then pressing **Stop** — no longer crashes the app.
|
||||
- **macOS:** pausing or stopping playback and then unplugging headphones (or switching the output device) no longer makes playback restart — it stays paused or stopped.
|
||||
|
||||
### Offline, Now Playing, and Navidrome
|
||||
|
||||
- On large **Navidrome** libraries, background library sync no longer locks up database writes for minutes at a time, so play history, ratings, and other saves go through without long delays.
|
||||
|
||||
### Themes and integrations
|
||||
|
||||
- **Discord** Rich Presence shows the album cover again when a server profile has both a local and a public address.
|
||||
|
||||
### Other
|
||||
|
||||
- **Windows:** the system media controls (Quick Settings media tile, lock screen, and third-party flyouts) now show the album cover and display **Psysonic** with its icon instead of "Unknown application".
|
||||
- **macOS:** closing the window with the red close button now respects **Minimize to Tray** — with it on, the window hides to the tray instead of quitting.
|
||||
|
||||
## [1.48.0]
|
||||
|
||||
|
||||
Generated
+3
-3
@@ -2,11 +2,11 @@
|
||||
"nodes": {
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1781074563,
|
||||
"narHash": "sha256-md8WlXOlfnIeHeOScMTTHFyf2d6iaTwPl2apR5EQ3P4=",
|
||||
"lastModified": 1781577229,
|
||||
"narHash": "sha256-lrp67w8AulE9Ks53n27I45ADSzbOCn4H+CNW1Ck8B+8=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "9ae611a455b90cf061d8f332b977e387bda8e1ca",
|
||||
"rev": "567a49d1913ce81ac6e9582e3553dd90a955875f",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
{
|
||||
"npmDepsHash": "sha256-ndXqYgws77qokAXznbQ6BXhXUo3VIaiF1AVs5jCkNCo="
|
||||
"npmDepsHash": "sha256-T2zrXt3cvqjmeoEOjTPoEiJKuzLv6R5QMuwTeNBSH/8="
|
||||
}
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "psysonic",
|
||||
"version": "1.48.0",
|
||||
"version": "1.48.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "psysonic",
|
||||
"version": "1.48.0",
|
||||
"version": "1.48.1",
|
||||
"dependencies": {
|
||||
"@fontsource-variable/dm-sans": "^5.2.8",
|
||||
"@fontsource-variable/figtree": "^5.2.10",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "psysonic",
|
||||
"version": "1.48.0",
|
||||
"version": "1.48.1",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"check:css-imports": "node scripts/check-css-import-graph.mjs",
|
||||
|
||||
Generated
+7
-7
@@ -4114,7 +4114,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "psysonic"
|
||||
version = "1.48.0"
|
||||
version = "1.48.1"
|
||||
dependencies = [
|
||||
"biquad",
|
||||
"dasp_sample",
|
||||
@@ -4167,7 +4167,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "psysonic-analysis"
|
||||
version = "1.48.0"
|
||||
version = "1.48.1"
|
||||
dependencies = [
|
||||
"ebur128",
|
||||
"futures-util",
|
||||
@@ -4186,7 +4186,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "psysonic-audio"
|
||||
version = "1.48.0"
|
||||
version = "1.48.1"
|
||||
dependencies = [
|
||||
"biquad",
|
||||
"dasp_sample",
|
||||
@@ -4216,7 +4216,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "psysonic-core"
|
||||
version = "1.48.0"
|
||||
version = "1.48.1"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"serde",
|
||||
@@ -4225,7 +4225,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "psysonic-integration"
|
||||
version = "1.48.0"
|
||||
version = "1.48.1"
|
||||
dependencies = [
|
||||
"discord-rich-presence",
|
||||
"futures-util",
|
||||
@@ -4242,7 +4242,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "psysonic-library"
|
||||
version = "1.48.0"
|
||||
version = "1.48.1"
|
||||
dependencies = [
|
||||
"psysonic-core",
|
||||
"psysonic-integration",
|
||||
@@ -4257,7 +4257,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "psysonic-syncfs"
|
||||
version = "1.48.0"
|
||||
version = "1.48.1"
|
||||
dependencies = [
|
||||
"futures-util",
|
||||
"id3",
|
||||
|
||||
@@ -3,7 +3,7 @@ members = ["crates/*"]
|
||||
resolver = "2"
|
||||
|
||||
[workspace.package]
|
||||
version = "1.48.0"
|
||||
version = "1.48.1"
|
||||
edition = "2021"
|
||||
rust-version = "1.95"
|
||||
license = "GPL-3.0-or-later"
|
||||
|
||||
@@ -448,7 +448,10 @@ impl<'a> TrackRepository<'a> {
|
||||
let mut remapped: Vec<RemapEntry> = Vec::new();
|
||||
let mut upsert = tx.prepare_cached(UPSERT_SQL)?;
|
||||
let mut remap_lookup = if unstable_track_ids {
|
||||
Some(tx.prepare_cached(REMAP_LOOKUP_SQL)?)
|
||||
Some((
|
||||
tx.prepare_cached(REMAP_LOOKUP_BY_HASH_SQL)?,
|
||||
tx.prepare_cached(REMAP_LOOKUP_BY_PATH_SQL)?,
|
||||
))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
@@ -459,11 +462,12 @@ impl<'a> TrackRepository<'a> {
|
||||
// then do we retarget children to the new id, since
|
||||
// child tables FK→track(server_id, id) and would refuse
|
||||
// an UPDATE pointing at an id that doesn't exist yet.
|
||||
let detected_old: Option<String> = if let Some(ref mut lookup) = remap_lookup {
|
||||
detect_remap_target_cached(lookup, r)?
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let detected_old: Option<String> =
|
||||
if let Some((ref mut by_hash, ref mut by_path)) = remap_lookup {
|
||||
detect_remap_target_cached(by_hash, by_path, r)?
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
upsert.execute(params![
|
||||
r.server_id,
|
||||
@@ -543,38 +547,76 @@ impl<'a> TrackRepository<'a> {
|
||||
}
|
||||
}
|
||||
|
||||
const REMAP_LOOKUP_SQL: &str = r#"
|
||||
// Two single-column lookups instead of one `OR` across `content_hash`
|
||||
// and `server_path`. The combined `OR` form could not use the partial
|
||||
// `idx_track_remap_hash` / `idx_track_remap_path` indexes — SQLite only
|
||||
// applies a partial index when the query's WHERE provably implies the
|
||||
// index predicate (`… != ''`), and an `OR` spanning two columns blocks
|
||||
// the per-branch index plan. The result was a full `track` scan per
|
||||
// incoming row → O(rows × catalog) on large libraries (observed:
|
||||
// `upsert_batch_remap exec_ms=162001` on a ~200k-track Navidrome sync).
|
||||
// Each statement below repeats the index predicate so the planner picks
|
||||
// the matching partial index (SEARCH, not SCAN); hash wins over path,
|
||||
// matching §6.9's strong-key priority.
|
||||
const REMAP_LOOKUP_BY_HASH_SQL: &str = r#"
|
||||
SELECT id FROM track
|
||||
WHERE server_id = ?1
|
||||
AND deleted = 0
|
||||
AND id != ?2
|
||||
AND (
|
||||
(?3 IS NOT NULL AND content_hash = ?3)
|
||||
OR (?4 IS NOT NULL AND server_path = ?4)
|
||||
)
|
||||
AND content_hash IS NOT NULL
|
||||
AND content_hash != ''
|
||||
AND content_hash = ?2
|
||||
AND id != ?3
|
||||
LIMIT 1
|
||||
"#;
|
||||
|
||||
const REMAP_LOOKUP_BY_PATH_SQL: &str = r#"
|
||||
SELECT id FROM track
|
||||
WHERE server_id = ?1
|
||||
AND deleted = 0
|
||||
AND server_path IS NOT NULL
|
||||
AND server_path != ''
|
||||
AND server_path = ?2
|
||||
AND id != ?3
|
||||
LIMIT 1
|
||||
"#;
|
||||
|
||||
/// Run the `SELECT old.id` half of §6.9 — returns `Some(old_id)` if a
|
||||
/// non-deleted row with a different id on this server matches the
|
||||
/// incoming row's `content_hash` or `server_path`.
|
||||
/// incoming row's `content_hash` or `server_path`. Hash is the stronger
|
||||
/// key, so it is checked first.
|
||||
fn detect_remap_target_cached(
|
||||
lookup: &mut rusqlite::Statement<'_>,
|
||||
by_hash: &mut rusqlite::Statement<'_>,
|
||||
by_path: &mut rusqlite::Statement<'_>,
|
||||
incoming: &TrackRow,
|
||||
) -> rusqlite::Result<Option<String>> {
|
||||
// Empty-string sentinels are *not* eligible — spec §6.9 explicitly
|
||||
// excludes them so the file-tree default never collides.
|
||||
let hash = incoming.content_hash.as_deref().filter(|s| !s.is_empty());
|
||||
let path = incoming.server_path.as_deref().filter(|s| !s.is_empty());
|
||||
if hash.is_none() && path.is_none() {
|
||||
return Ok(None);
|
||||
|
||||
if let Some(hash) = hash {
|
||||
let old = by_hash
|
||||
.query_row(params![incoming.server_id, hash, incoming.id], |row| {
|
||||
row.get::<_, String>(0)
|
||||
})
|
||||
.optional()?;
|
||||
if old.is_some() {
|
||||
return Ok(old);
|
||||
}
|
||||
}
|
||||
lookup
|
||||
.query_row(
|
||||
params![incoming.server_id, incoming.id, hash, path],
|
||||
|row| row.get::<_, String>(0),
|
||||
)
|
||||
.optional()
|
||||
|
||||
if let Some(path) = path {
|
||||
let old = by_path
|
||||
.query_row(params![incoming.server_id, path, incoming.id], |row| {
|
||||
row.get::<_, String>(0)
|
||||
})
|
||||
.optional()?;
|
||||
if old.is_some() {
|
||||
return Ok(old);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
/// Run the §6.9 retarget half — UPDATE every FK-bound child to the
|
||||
@@ -1247,6 +1289,48 @@ mod tests {
|
||||
assert_eq!(count, 2, "both rows kept; identity-less rows can't shadow");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remap_lookup_uses_partial_indexes_not_full_scan() {
|
||||
// Regression: the §6.9 remap lookup must hit
|
||||
// idx_track_remap_hash / idx_track_remap_path. The prior
|
||||
// `OR`-based query fell back to a full `track` scan on every
|
||||
// incoming row → O(rows × catalog) stalls on large libraries
|
||||
// (`upsert_batch_remap exec_ms=162001` on a ~200k Navidrome sync).
|
||||
let store = LibraryStore::open_in_memory();
|
||||
let plan = |sql: &str| -> String {
|
||||
store
|
||||
.with_conn("misc", |c| {
|
||||
let mut stmt = c.prepare(&format!("EXPLAIN QUERY PLAN {sql}"))?;
|
||||
let rows: rusqlite::Result<Vec<String>> = stmt
|
||||
.query_map(params!["s1", "v", "id"], |r| r.get::<_, String>(3))?
|
||||
.collect();
|
||||
rows
|
||||
})
|
||||
.unwrap()
|
||||
.join("\n")
|
||||
};
|
||||
|
||||
let hash_plan = plan(REMAP_LOOKUP_BY_HASH_SQL);
|
||||
assert!(
|
||||
hash_plan.contains("idx_track_remap_hash"),
|
||||
"hash lookup must use idx_track_remap_hash, got: {hash_plan}"
|
||||
);
|
||||
assert!(
|
||||
!hash_plan.contains("SCAN"),
|
||||
"hash lookup must not full-scan track, got: {hash_plan}"
|
||||
);
|
||||
|
||||
let path_plan = plan(REMAP_LOOKUP_BY_PATH_SQL);
|
||||
assert!(
|
||||
path_plan.contains("idx_track_remap_path"),
|
||||
"path lookup must use idx_track_remap_path, got: {path_plan}"
|
||||
);
|
||||
assert!(
|
||||
!path_plan.contains("SCAN"),
|
||||
"path lookup must not full-scan track, got: {path_plan}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remap_is_noop_when_new_id_matches_existing_id() {
|
||||
// Standard delta-sync: same id, same hash. Must not trigger
|
||||
|
||||
@@ -64,7 +64,28 @@ fn on_second_instance<R: tauri::Runtime>(
|
||||
}
|
||||
}
|
||||
|
||||
/// Windows: associate this process with an explicit AppUserModelID. Windows uses
|
||||
/// it to name the app in taskbar grouping and the SMTC media controls; without it
|
||||
/// the media tile reads "Unknown application". Must match the AppUserModelID the
|
||||
/// installer sets on the Start-menu shortcut so the name/icon resolve.
|
||||
#[cfg(target_os = "windows")]
|
||||
fn set_app_user_model_id() {
|
||||
use windows::core::w;
|
||||
use windows::Win32::UI::Shell::SetCurrentProcessExplicitAppUserModelID;
|
||||
// SAFETY: a Win32 call with a static wide string; errors are non-fatal.
|
||||
unsafe {
|
||||
let _ = SetCurrentProcessExplicitAppUserModelID(w!("dev.psysonic.player"));
|
||||
}
|
||||
}
|
||||
|
||||
pub fn run() {
|
||||
// Windows: bind this process to an explicit AppUserModelID before any window
|
||||
// or the SMTC media controls are created, so the OS can resolve the app
|
||||
// name/icon for taskbar grouping and the media tile (#1102 follow-up: the
|
||||
// Quick-Settings / lock-screen media tile showed "Unknown application").
|
||||
#[cfg(target_os = "windows")]
|
||||
set_app_user_model_id();
|
||||
|
||||
// Linux: second `psysonic --player …` forwards over D-Bus before heavy startup.
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
|
||||
@@ -84,6 +84,15 @@ pub(crate) fn mpris_set_metadata(
|
||||
let duration = duration_secs.map(Duration::from_secs_f64);
|
||||
let mut guard = controls.lock().unwrap();
|
||||
let Some(ctrl) = guard.as_mut() else { return Ok(()); };
|
||||
|
||||
// #1102: Windows SMTC cannot render our cached WebP covers. souvlaki loads
|
||||
// the file and SetThumbnail/set_metadata succeed, but the lock screen and
|
||||
// Quick-Settings media tile show a blank cover (the OS thumbnail decoder
|
||||
// does not handle WebP, even with the Store WebP extension installed).
|
||||
// Transcode local WebP covers to PNG for the OS media controls; macOS
|
||||
// (ImageIO) decodes WebP fine, so other platforms pass through unchanged.
|
||||
let cover_url = smtc_cover_url(cover_url);
|
||||
|
||||
ctrl.set_metadata(MediaMetadata {
|
||||
title: title.as_deref(),
|
||||
artist: artist.as_deref(),
|
||||
@@ -94,6 +103,48 @@ pub(crate) fn mpris_set_metadata(
|
||||
.map_err(|e| format!("MPRIS set_metadata failed: {e:?}"))
|
||||
}
|
||||
|
||||
/// Rewrite a cached WebP cover URL to a PNG the OS media controls can render.
|
||||
/// Windows SMTC cannot decode WebP thumbnails (#1102); other platforms and any
|
||||
/// non-`file://`/non-WebP URL pass through unchanged.
|
||||
fn smtc_cover_url(cover_url: Option<String>) -> Option<String> {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
if let Some(url) = cover_url.as_deref() {
|
||||
if let Some(path) = url.strip_prefix("file://") {
|
||||
let is_webp = std::path::Path::new(path)
|
||||
.extension()
|
||||
.is_some_and(|e| e.eq_ignore_ascii_case("webp"));
|
||||
if is_webp {
|
||||
match webp_file_to_temp_png(path) {
|
||||
Ok(png) => return Some(format!("file://{png}")),
|
||||
Err(e) => {
|
||||
crate::app_eprintln!("[mpris] cover WebP->PNG transcode failed: {e}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
cover_url
|
||||
}
|
||||
|
||||
/// Decode a WebP file (libwebp, the same codec that wrote the cover cache) and
|
||||
/// re-encode it as a PNG in the temp dir, returning the native path. A single
|
||||
/// reusable file is fine: souvlaki reads it synchronously inside `set_metadata`,
|
||||
/// and the controls mutex serializes calls so it is never written concurrently.
|
||||
#[cfg(target_os = "windows")]
|
||||
fn webp_file_to_temp_png(webp_path: &str) -> Result<String, String> {
|
||||
let bytes = std::fs::read(webp_path).map_err(|e| e.to_string())?;
|
||||
let decoded = webp::Decoder::new(&bytes)
|
||||
.decode()
|
||||
.ok_or_else(|| "WebP decode returned None".to_string())?;
|
||||
let img = decoded.to_image();
|
||||
let out = std::env::temp_dir().join("psysonic-smtc-cover.png");
|
||||
img.save_with_format(&out, image::ImageFormat::Png)
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(out.to_string_lossy().into_owned())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn mpris_set_playback(
|
||||
controls: tauri::State<MprisControls>,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Psysonic",
|
||||
"version": "1.48.0",
|
||||
"version": "1.48.1",
|
||||
"identifier": "dev.psysonic.player",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
|
||||
Reference in New Issue
Block a user