diff --git a/README.md b/README.md index 9f3b774..220a2f5 100644 --- a/README.md +++ b/README.md @@ -90,10 +90,22 @@ their catalog MD5, and the normal archive safety checks still apply. No catalog request is made while the browser window is closed. Use **Import local skin** in that window to import a `.wsz` or `.zip` from disk. -Missing individual resources fall back to the original project artwork. The -generated fixture at -[`assets/default-skin/model-275.wsz`](assets/default-skin/model-275.wsz) -is MIT/Apache-2.0 and may be redistributed. +Missing individual resources fall back to the original project artwork. +Installed skins are selected from a fixed-width dropdown, so large collections +do not make the browser scroll horizontally. Imported and downloaded skins can +be deleted there; deleting the active skin falls back to Model 275. Bundled +skins are labelled and retained because they would otherwise return on restart. +Tonelag bundles the dark **Model 275** default and the light **Pastellplate** +skin; Pastellplate is installed as a selectable local skin without changing the +user's current selection. Both generated archives in +[`assets/default-skin`](assets/default-skin) are MIT/Apache-2.0 and may be +redistributed. Rebuild Pastellplate and its preview with +`python3 scripts/generate_pastellplate_skin.py`. + +The original title-bar option hotspot opens the Tonelag menu for app-specific +actions such as local files, stream URLs, skin browsing, language, always-on-top +and 2× mode. Keeping these actions in one menu avoids drawing non-classic buttons +over third-party skin artwork. Museum skins are third-party works and are installed into the user's app-data directory; they are not committed to this repository or bundled in releases. diff --git a/assets/branding/pastellplate-preview.png b/assets/branding/pastellplate-preview.png new file mode 100644 index 0000000..a7487fb Binary files /dev/null and b/assets/branding/pastellplate-preview.png differ diff --git a/assets/branding/tonelag-icon-pastel-source.png b/assets/branding/tonelag-icon-pastel-source.png new file mode 100644 index 0000000..76ed274 Binary files /dev/null and b/assets/branding/tonelag-icon-pastel-source.png differ diff --git a/assets/branding/tonelag-icon-pastel.png b/assets/branding/tonelag-icon-pastel.png new file mode 100644 index 0000000..585e733 Binary files /dev/null and b/assets/branding/tonelag-icon-pastel.png differ diff --git a/assets/branding/tonelag-icon.png b/assets/branding/tonelag-icon.png new file mode 100644 index 0000000..585e733 Binary files /dev/null and b/assets/branding/tonelag-icon.png differ diff --git a/assets/default-skin/pastellplate.wsz b/assets/default-skin/pastellplate.wsz new file mode 100644 index 0000000..bbd5022 Binary files /dev/null and b/assets/default-skin/pastellplate.wsz differ diff --git a/scripts/generate_pastellplate_skin.py b/scripts/generate_pastellplate_skin.py new file mode 100644 index 0000000..144c405 --- /dev/null +++ b/scripts/generate_pastellplate_skin.py @@ -0,0 +1,333 @@ +"""Generate Tonelag's original Pastellplate classic skin reproducibly.""" + +from pathlib import Path +import struct +import zlib +import zipfile + + +WIDTH = 275 +HEIGHT = 116 +CREAM = (255, 248, 231) +CREAM_DARK = (245, 229, 205) +LAVENDER = (188, 162, 245) +LAVENDER_DARK = (153, 124, 220) +BLUE = (146, 200, 255) +PINK = (248, 180, 210) +PEACH = (255, 208, 157) +PLUM = (62, 49, 90) +PLUM_SOFT = (86, 70, 111) +WHITE = (255, 253, 247) + + +class Canvas: + def __init__(self, width: int, height: int, fill=CREAM): + self.width = width + self.height = height + self.pixels = [[fill for _ in range(width)] for _ in range(height)] + + def set(self, x: int, y: int, color) -> None: + if 0 <= x < self.width and 0 <= y < self.height: + self.pixels[y][x] = color + + def rect(self, x: int, y: int, width: int, height: int, color) -> None: + for yy in range(max(0, y), min(self.height, y + height)): + for xx in range(max(0, x), min(self.width, x + width)): + self.pixels[yy][xx] = color + + def frame(self, x: int, y: int, width: int, height: int, color, inner=WHITE) -> None: + self.rect(x, y, width, height, color) + self.rect(x + 1, y + 1, width - 2, height - 2, inner) + + def line(self, x0: int, y0: int, x1: int, y1: int, color) -> None: + dx, dy = abs(x1 - x0), -abs(y1 - y0) + sx, sy = (1 if x0 < x1 else -1), (1 if y0 < y1 else -1) + error = dx + dy + while True: + self.set(x0, y0, color) + if x0 == x1 and y0 == y1: + break + twice = 2 * error + if twice >= dy: + error += dy + x0 += sx + if twice <= dx: + error += dx + y0 += sy + + def circle(self, cx: int, cy: int, radius: int, color) -> None: + for y in range(cy - radius, cy + radius + 1): + for x in range(cx - radius, cx + radius + 1): + if (x - cx) ** 2 + (y - cy) ** 2 <= radius ** 2: + self.set(x, y, color) + + def bmp(self) -> bytes: + padding = (-self.width * 3) % 4 + rows = bytearray() + for row in reversed(self.pixels): + for red, green, blue in row: + rows.extend((blue, green, red)) + rows.extend(b"\0" * padding) + offset = 54 + header = b"BM" + struct.pack(" bytes: + rows = [] + for row in self.pixels: + scanline = bytearray([0]) + for red, green, blue in row: + scanline.extend((red, green, blue, 255)) + rows.append(scanline) + raw = b"".join(rows) + header = struct.pack(">IIBBBBB", self.width, self.height, 8, 6, 0, 0, 0) + return ( + b"\x89PNG\r\n\x1a\n" + + png_chunk(b"IHDR", header) + + png_chunk(b"IDAT", zlib.compress(raw, 9)) + + png_chunk(b"IEND", b"") + ) + + +def png_chunk(kind: bytes, data: bytes) -> bytes: + return struct.pack(">I", len(data)) + kind + data + struct.pack(">I", zlib.crc32(kind + data)) + + +def shell(title_accent=LAVENDER) -> Canvas: + canvas = Canvas(WIDTH, HEIGHT) + canvas.rect(0, 0, WIDTH, HEIGHT, PLUM) + canvas.rect(1, 1, WIDTH - 2, HEIGHT - 2, CREAM) + canvas.rect(2, 2, WIDTH - 4, 12, title_accent) + canvas.rect(3, 3, WIDTH - 6, 2, WHITE) + for x in range(7, 110, 8): + canvas.rect(x, 7, 5, 2, CREAM) + for x in range(165, 258, 8): + canvas.rect(x, 7, 5, 2, CREAM) + canvas.circle(137, 8, 4, CREAM) + canvas.circle(137, 8, 2, PINK) + canvas.rect(4, 15, WIDTH - 8, 2, CREAM_DARK) + return canvas + + +def main_bitmap() -> Canvas: + canvas = shell(LAVENDER) + canvas.frame(12, 21, 251, 50, PLUM, PLUM_SOFT) + canvas.rect(15, 24, 245, 44, (46, 38, 67)) + canvas.rect(18, 27, 68, 36, (52, 43, 76)) + canvas.rect(90, 27, 167, 16, (58, 47, 82)) + for x, color in enumerate((PINK, PEACH, LAVENDER, BLUE) * 9): + height = 5 + ((x * 7) % 15) + canvas.rect(93 + x * 4, 63 - height, 2, height, color) + canvas.rect(5, 76, 265, 35, CREAM_DARK) + canvas.rect(8, 79, 139, 27, BLUE) + canvas.rect(10, 81, 135, 23, CREAM) + canvas.rect(153, 79, 114, 27, PINK) + canvas.rect(155, 81, 110, 23, CREAM) + for x, color in ((16, LAVENDER), (40, BLUE), (64, PINK), (88, PEACH), (112, LAVENDER)): + canvas.circle(x, 93, 7, color) + canvas.circle(x, 93, 3, WHITE) + canvas.rect(163, 88, 91, 3, PLUM_SOFT) + canvas.rect(163, 97, 91, 3, PLUM_SOFT) + canvas.circle(224, 89, 5, BLUE) + canvas.circle(191, 98, 5, PINK) + return canvas + + +def eq_bitmap() -> Canvas: + base = shell(BLUE) + canvas = Canvas(WIDTH, 315, PLUM) + canvas.pixels[:HEIGHT] = [list(row) for row in base.pixels] + canvas.rect(7, 20, 261, 89, CREAM_DARK) + canvas.rect(10, 23, 255, 83, CREAM) + canvas.frame(14, 27, 35, 72, LAVENDER_DARK, (238, 226, 255)) + for x in range(60, 253, 19): + canvas.rect(x, 30, 5, 64, PLUM_SOFT) + canvas.rect(x + 1, 31, 3, 62, (224, 211, 239)) + knob_y = 43 + ((x * 3) % 35) + canvas.rect(x - 3, knob_y, 11, 6, LAVENDER if (x // 19) % 2 else PINK) + canvas.rect(x - 2, knob_y + 1, 9, 1, WHITE) + for y in (34, 62, 90): + canvas.rect(18, y, 27, 2, PLUM_SOFT) + canvas.rect(25, 53, 12, 8, PEACH) + # Standard EQMAIN sprite area used by Winamp 2.x compatible clients. + for y, color in ((116, BLUE), (125, LAVENDER)): + canvas.rect(0, y, 9, 9, color) + canvas.line(2, y + 2, 6, y + 6, PLUM) + canvas.line(6, y + 2, 2, y + 6, PLUM) + for x, color in ((10, BLUE), (69, LAVENDER), (128, PEACH), (187, PINK)): + canvas.rect(x, 119, 26, 12, color) + canvas.rect(x + 2, 121, 22, 2, WHITE) + canvas.rect(0, 164, 11, 11, LAVENDER) + canvas.rect(2, 166, 7, 2, WHITE) + canvas.rect(0, 176, 11, 11, LAVENDER_DARK) + canvas.rect(224, 164, 44, 12, PINK) + canvas.rect(226, 166, 40, 2, WHITE) + canvas.rect(224, 176, 44, 12, PEACH) + canvas.rect(0, 294, 113, 19, (46, 38, 67)) + for x in range(4, 110, 10): + canvas.rect(x, 302 - (x % 4), 2, 6 + (x % 5), BLUE if x % 20 else PINK) + canvas.rect(115, 294, 1, 19, LAVENDER) + canvas.rect(0, 314, 113, 1, PEACH) + return canvas + + +def playlist_bitmap() -> Canvas: + base = shell(PINK) + canvas = Canvas(280, 186, PLUM) + for y, row in enumerate(base.pixels): + canvas.pixels[y][:WIDTH] = list(row) + canvas.frame(7, 20, 261, 88, PLUM_SOFT, WHITE) + canvas.rect(10, 23, 255, 62, (255, 250, 238)) + for y in range(25, 84, 10): + canvas.rect(12, y, 251, 1, (232, 218, 240)) + canvas.rect(10, 88, 255, 17, CREAM_DARK) + for x, color in ((15, LAVENDER), (66, BLUE), (117, PINK), (168, PEACH), (219, LAVENDER)): + canvas.rect(x, 92, 42, 9, color) + canvas.rect(x + 1, 93, 40, 2, WHITE) + # Menu sprites at the canonical PLEDIT coordinates. + for x, color in ((0, BLUE), (23, LAVENDER), (54, PINK), (77, PEACH), (104, LAVENDER), + (127, BLUE), (154, PEACH), (177, PINK), (204, LAVENDER), (227, BLUE)): + for y in (111, 130, 149): + canvas.rect(x, y, 22, 18, PLUM) + canvas.rect(x + 1, y + 1, 20, 16, color) + canvas.rect(x + 3, y + 4, 16, 2, WHITE) + return canvas + + +def titlebar_bitmap() -> Canvas: + canvas = Canvas(320, 87, PLUM) + for y, accent in ((0, LAVENDER), (15, BLUE)): + canvas.rect(27, y, 275, 14, accent) + canvas.rect(29, y + 2, 271, 2, WHITE) + for x in range(37, 285, 8): + canvas.rect(x, y + 7, 5, 2, CREAM) + for x, color in ((0, BLUE), (9, LAVENDER), (18, PINK)): + canvas.rect(x, 0, 9, 9, color) + canvas.rect(x, 9, 9, 9, tuple(max(0, channel - 25) for channel in color)) + canvas.line(20, 2, 24, 6, PLUM) + canvas.line(24, 2, 20, 6, PLUM) + return canvas + + +def shufrep_bitmap() -> Canvas: + canvas = Canvas(92, 85, PLUM) + for y, shade in ((0, 0), (15, 20), (30, -12), (45, 8)): + repeat = tuple(max(0, min(255, channel + shade)) for channel in BLUE) + shuffle = tuple(max(0, min(255, channel + shade)) for channel in PINK) + canvas.rect(0, y, 28, 15, repeat) + canvas.rect(28, y, 47, 15, shuffle) + canvas.rect(2, y + 2, 24, 2, WHITE) + canvas.rect(30, y + 2, 43, 2, WHITE) + for x, color in ((0, LAVENDER), (23, PEACH), (46, BLUE), (69, PINK)): + canvas.rect(x, 61, 23, 12, color) + canvas.rect(x, 73, 23, 12, tuple(max(0, channel - 20) for channel in color)) + return canvas + + +def posbar_bitmap() -> Canvas: + canvas = Canvas(307, 10, CREAM_DARK) + canvas.rect(0, 3, 248, 4, PLUM_SOFT) + canvas.rect(1, 4, 246, 1, WHITE) + canvas.rect(248, 0, 29, 10, LAVENDER) + canvas.rect(250, 2, 25, 2, WHITE) + canvas.rect(278, 0, 29, 10, LAVENDER_DARK) + return canvas + + +def controls_bitmap() -> Canvas: + canvas = Canvas(136, 36, CREAM_DARK) + widths = [23, 23, 23, 23, 22, 22] + colors = [LAVENDER, BLUE, PINK, PEACH, LAVENDER, BLUE] + start = 0 + for index, width in enumerate(widths): + for row in range(2): + y = row * 18 + color = colors[index] if row == 0 else tuple(max(0, channel - 22) for channel in colors[index]) + canvas.rect(start, y, width, 18, PLUM) + canvas.rect(start + 1, y + 1, width - 2, 16, color) + canvas.rect(start + 2, y + 2, width - 4, 2, WHITE) + cx, cy = start + width // 2, y + 9 + if index == 0: + canvas.line(cx + 3, cy - 4, cx - 3, cy, PLUM) + canvas.line(cx - 3, cy, cx + 3, cy + 4, PLUM) + elif index in (1, 3): + direction = 1 if index == 1 else -1 + for step in range(5): + canvas.line(cx - direction * 3, cy - 4 + step, cx + direction * 3, cy, PLUM) + canvas.line(cx + direction * 3, cy, cx - direction * 3, cy + 4 - step, PLUM) + elif index == 2: + canvas.rect(cx - 3, cy - 4, 3, 8, PLUM) + canvas.rect(cx + 2, cy - 4, 3, 8, PLUM) + elif index == 4: + canvas.rect(cx - 4, cy - 4, 8, 8, PLUM) + else: + canvas.line(cx - 4, cy + 4, cx, cy - 4, PLUM) + canvas.line(cx, cy - 4, cx + 4, cy + 4, PLUM) + start += width + return canvas + + +def glyph_bitmap(color, width=64, height=16) -> Canvas: + canvas = Canvas(width, height, PLUM) + for x in range(3, width - 3, 8): + canvas.rect(x, 3, 5, 10, color) + canvas.rect(x + 1, 4, 3, 2, WHITE) + return canvas + + +def make_skin(output: Path, preview: Path) -> None: + main = main_bitmap() + equalizer = eq_bitmap() + playlist = playlist_bitmap() + files = { + "MAIN.BMP": main.bmp(), + "EQMAIN.BMP": equalizer.bmp(), + "PLEDIT.BMP": playlist.bmp(), + "CBUTTONS.BMP": controls_bitmap().bmp(), + "TITLEBAR.BMP": titlebar_bitmap().bmp(), + "SHUFREP.BMP": shufrep_bitmap().bmp(), + "POSBAR.BMP": posbar_bitmap().bmp(), + "TEXT.BMP": glyph_bitmap(LAVENDER).bmp(), + "NUMBERS.BMP": glyph_bitmap(PEACH).bmp(), + "PLEDIT.TXT": ( + b"[Text]\r\nNormal=#56466F\r\nCurrent=#3E315A\r\n" + b"NormalBG=#FFF8E7\r\nSelectedBG=#E6D8FA\r\n" + ), + "VISCOLOR.TXT": b"248,180,210\r\n255,208,157\r\n188,162,245\r\n146,200,255\r\n255,248,231\r\n", + "REGION.TXT": ( + b"[Normal]\r\nNumPoints=4\r\nPointList=0,0,275,0,275,116,0,116\r\n" + b"[Equalizer]\r\nNumPoints=4\r\nPointList=0,0,275,0,275,116,0,116\r\n" + ), + "SKIN.HINTS": b"Pastellplate - an original Tonelag skin\r\n", + "GENEX.COLS": b"window=#FFF8E7\r\nwindowtext=#56466F\r\nbutton=#BCA2F5\r\n", + "LICENSE.txt": ( + b"Pastellplate artwork Copyright 2026 Tonelag contributors.\r\n" + b"SPDX-License-Identifier: MIT OR Apache-2.0\r\n" + ), + } + output.parent.mkdir(parents=True, exist_ok=True) + with zipfile.ZipFile(output, "w", zipfile.ZIP_DEFLATED, compresslevel=9) as archive: + for name in sorted(files): + info = zipfile.ZipInfo(name, date_time=(2026, 1, 1, 0, 0, 0)) + info.compress_type = zipfile.ZIP_DEFLATED + info.external_attr = 0o100644 << 16 + archive.writestr(info, files[name]) + + stacked = Canvas(WIDTH, HEIGHT * 3) + for offset, source in enumerate((main, equalizer, playlist)): + for y, row in enumerate(source.pixels[:HEIGHT]): + stacked.pixels[offset * HEIGHT + y] = list(row[:WIDTH]) + preview.parent.mkdir(parents=True, exist_ok=True) + preview.write_bytes(stacked.png()) + + +if __name__ == "__main__": + root = Path(__file__).resolve().parents[1] + make_skin( + root / "assets" / "default-skin" / "pastellplate.wsz", + root / "assets" / "branding" / "pastellplate-preview.png", + ) diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 0b61374..edf3875 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -5118,6 +5118,7 @@ dependencies = [ "futures-util", "log", "md-5", + "objc2-app-kit", "reqwest", "rtrb", "rustfft", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index c5672fb..f9eadcf 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -45,5 +45,8 @@ url = { version = "2.5.8", features = ["serde"] } uuid = { version = "1.21.0", features = ["v4", "serde"] } zip = { version = "6.0.0", default-features = false, features = ["deflate"] } +[target.'cfg(target_os = "macos")'.dependencies] +objc2-app-kit = { version = "0.3.2", default-features = false, features = ["NSGraphics", "NSWindow"] } + [dev-dependencies] tempfile = "3.26.0" diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index 47e6711..e0e6813 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -6,6 +6,7 @@ "permissions": [ "core:default", "core:event:default", + "core:webview:allow-set-webview-zoom", "core:window:allow-start-dragging", "core:window:allow-set-size", "core:window:allow-set-position", diff --git a/src-tauri/gen/schemas/capabilities.json b/src-tauri/gen/schemas/capabilities.json index 8c9fd75..49a46e0 100644 --- a/src-tauri/gen/schemas/capabilities.json +++ b/src-tauri/gen/schemas/capabilities.json @@ -1 +1 @@ -{"desktop-default":{"identifier":"desktop-default","description":"Permissions for Tonelag player and skin browser windows","local":true,"windows":["main","equalizer","playlist","skins"],"permissions":["core:default","core:event:default","core:window:allow-start-dragging","core:window:allow-set-size","core:window:allow-set-position","core:window:allow-set-always-on-top","dialog:allow-open","dialog:allow-save"]}} \ No newline at end of file +{"desktop-default":{"identifier":"desktop-default","description":"Permissions for Tonelag player and skin browser windows","local":true,"windows":["main","equalizer","playlist","skins"],"permissions":["core:default","core:event:default","core:webview:allow-set-webview-zoom","core:window:allow-start-dragging","core:window:allow-set-size","core:window:allow-set-position","core:window:allow-set-always-on-top","dialog:allow-open","dialog:allow-save"]}} \ No newline at end of file diff --git a/src-tauri/icons/128x128.png b/src-tauri/icons/128x128.png index d69eb6f..21effff 100644 Binary files a/src-tauri/icons/128x128.png and b/src-tauri/icons/128x128.png differ diff --git a/src-tauri/icons/128x128@2x.png b/src-tauri/icons/128x128@2x.png index 4997280..fe34a50 100644 Binary files a/src-tauri/icons/128x128@2x.png and b/src-tauri/icons/128x128@2x.png differ diff --git a/src-tauri/icons/32x32.png b/src-tauri/icons/32x32.png index 879277a..cce420d 100644 Binary files a/src-tauri/icons/32x32.png and b/src-tauri/icons/32x32.png differ diff --git a/src-tauri/icons/64x64.png b/src-tauri/icons/64x64.png index e9f4d1c..689dfaf 100644 Binary files a/src-tauri/icons/64x64.png and b/src-tauri/icons/64x64.png differ diff --git a/src-tauri/icons/Square107x107Logo.png b/src-tauri/icons/Square107x107Logo.png index 6511179..eb324bd 100644 Binary files a/src-tauri/icons/Square107x107Logo.png and b/src-tauri/icons/Square107x107Logo.png differ diff --git a/src-tauri/icons/Square142x142Logo.png b/src-tauri/icons/Square142x142Logo.png index a52baf1..cde16ff 100644 Binary files a/src-tauri/icons/Square142x142Logo.png and b/src-tauri/icons/Square142x142Logo.png differ diff --git a/src-tauri/icons/Square150x150Logo.png b/src-tauri/icons/Square150x150Logo.png index 2a27c57..fd19ff3 100644 Binary files a/src-tauri/icons/Square150x150Logo.png and b/src-tauri/icons/Square150x150Logo.png differ diff --git a/src-tauri/icons/Square284x284Logo.png b/src-tauri/icons/Square284x284Logo.png index c4d3593..05ff7bd 100644 Binary files a/src-tauri/icons/Square284x284Logo.png and b/src-tauri/icons/Square284x284Logo.png differ diff --git a/src-tauri/icons/Square30x30Logo.png b/src-tauri/icons/Square30x30Logo.png index 2795f8a..90f20c1 100644 Binary files a/src-tauri/icons/Square30x30Logo.png and b/src-tauri/icons/Square30x30Logo.png differ diff --git a/src-tauri/icons/Square310x310Logo.png b/src-tauri/icons/Square310x310Logo.png index 79c01a1..7d0b73a 100644 Binary files a/src-tauri/icons/Square310x310Logo.png and b/src-tauri/icons/Square310x310Logo.png differ diff --git a/src-tauri/icons/Square44x44Logo.png b/src-tauri/icons/Square44x44Logo.png index 4bdae30..e2bb276 100644 Binary files a/src-tauri/icons/Square44x44Logo.png and b/src-tauri/icons/Square44x44Logo.png differ diff --git a/src-tauri/icons/Square71x71Logo.png b/src-tauri/icons/Square71x71Logo.png index 08a27a7..8441cff 100644 Binary files a/src-tauri/icons/Square71x71Logo.png and b/src-tauri/icons/Square71x71Logo.png differ diff --git a/src-tauri/icons/Square89x89Logo.png b/src-tauri/icons/Square89x89Logo.png index a506213..f5ba499 100644 Binary files a/src-tauri/icons/Square89x89Logo.png and b/src-tauri/icons/Square89x89Logo.png differ diff --git a/src-tauri/icons/StoreLogo.png b/src-tauri/icons/StoreLogo.png index 72f2b2c..80c55bb 100644 Binary files a/src-tauri/icons/StoreLogo.png and b/src-tauri/icons/StoreLogo.png differ diff --git a/src-tauri/icons/icon.icns b/src-tauri/icons/icon.icns index 0e2e42d..dddb6a2 100644 Binary files a/src-tauri/icons/icon.icns and b/src-tauri/icons/icon.icns differ diff --git a/src-tauri/icons/icon.ico b/src-tauri/icons/icon.ico index f70151e..952554f 100644 Binary files a/src-tauri/icons/icon.ico and b/src-tauri/icons/icon.ico differ diff --git a/src-tauri/icons/icon.png b/src-tauri/icons/icon.png index c43d075..4e7e11d 100644 Binary files a/src-tauri/icons/icon.png and b/src-tauri/icons/icon.png differ diff --git a/src-tauri/src/controller.rs b/src-tauri/src/controller.rs index 7d51abd..3aae90e 100644 --- a/src-tauri/src/controller.rs +++ b/src-tauri/src/controller.rs @@ -1,6 +1,6 @@ use std::{ path::{Path, PathBuf}, - sync::Mutex, + sync::{Arc, Mutex}, }; use anyhow::{Context, Result, anyhow}; @@ -15,40 +15,38 @@ use crate::{ }; pub struct AppController { - state: Mutex, + state: Arc>, data_dir: PathBuf, - audio: AudioController, + deferred_saver: persistence::DeferredSaver, + audio: Mutex>, system_media: Mutex>>, } impl AppController { - pub fn new( - data_dir: PathBuf, - ) -> Result<(Self, tokio::sync::mpsc::UnboundedReceiver)> { + pub fn new(data_dir: PathBuf) -> Result { let snapshot = persistence::load(&data_dir).unwrap_or_else(|error| { log::warn!("Could not restore the previous session: {error:#}"); AppSnapshot::default() }); - let (audio, events) = AudioController::new( - snapshot.settings.volume, - snapshot.settings.balance, - snapshot.settings.eq.clone(), - )?; - Ok(( - Self { - state: Mutex::new(snapshot), - data_dir, - audio, - system_media: Mutex::new(None), - }, - events, - )) + let state = Arc::new(Mutex::new(snapshot)); + let deferred_saver = persistence::DeferredSaver::new(data_dir.clone(), state.clone())?; + Ok(Self { + state, + data_dir, + deferred_saver, + audio: Mutex::new(None), + system_media: Mutex::new(None), + }) } pub fn snapshot(&self) -> AppSnapshot { self.state.lock().expect("state lock poisoned").clone() } + pub fn save_before_exit(&self) -> Result<()> { + persistence::save(&self.data_dir, &self.snapshot()) + } + pub fn attach_system_media( &self, sender: std::sync::mpsc::Sender, @@ -61,6 +59,44 @@ impl AppController { .expect("system media lock poisoned") = Some(sender); } + pub fn initialize_audio(&self, app: &AppHandle) -> Result<()> { + let mut audio = self.audio.lock().expect("audio lock poisoned"); + if audio.is_some() { + return Ok(()); + } + let settings = self + .state + .lock() + .expect("state lock poisoned") + .settings + .clone(); + let (controller, mut events) = + AudioController::new(settings.volume, settings.balance, settings.eq)?; + *audio = Some(controller); + drop(audio); + + let app_handle = app.clone(); + let event_controller = app.state::>().inner().clone(); + tauri::async_runtime::spawn(async move { + while let Some(event) = events.recv().await { + event_controller.handle_audio_event(event, &app_handle); + } + }); + Ok(()) + } + + fn send_audio(&self, command: AudioCommand, app: &AppHandle) -> Result<()> { + self.initialize_audio(app)?; + self.send_audio_if_initialized(command) + } + + fn send_audio_if_initialized(&self, command: AudioCommand) -> Result<()> { + if let Some(audio) = self.audio.lock().expect("audio lock poisoned").as_ref() { + audio.send(command)?; + } + Ok(()) + } + pub fn add_paths(&self, paths: Vec, app: &AppHandle) -> Result { let mut items = Vec::new(); for raw in paths { @@ -107,7 +143,7 @@ impl AppController { .current_item_id .is_some_and(|id| ids.contains(&id)); if removing_current { - self.audio.send(AudioCommand::Stop)?; + self.send_audio_if_initialized(AudioCommand::Stop)?; } let snapshot = self.mutate_persistent(|state| { state.queue.retain(|item| !ids.contains(&item.id)); @@ -125,7 +161,7 @@ impl AppController { } pub fn clear_queue(&self, app: &AppHandle) -> Result { - self.audio.send(AudioCommand::Stop)?; + self.send_audio_if_initialized(AudioCommand::Stop)?; let snapshot = self.mutate_persistent(|state| { state.queue.clear(); state.playback = Default::default(); @@ -152,7 +188,7 @@ impl AppController { pub fn import_eqf(&self, path: &Path, app: &AppHandle) -> Result { let eq = eqf::import(path)?; - self.audio.send(AudioCommand::SetEq(eq.clone()))?; + self.send_audio_if_initialized(AudioCommand::SetEq(eq.clone()))?; let snapshot = self.mutate_persistent(|state| state.settings.eq = eq)?; emit_snapshot(app, &snapshot); Ok(snapshot) @@ -172,6 +208,46 @@ impl AppController { self.select_skin(descriptor, app) } + pub fn install_bundled_skin( + &self, + path: &Path, + name: &str, + obsolete_ids: &[&str], + ) -> Result { + let bytes = std::fs::read(path) + .with_context(|| format!("failed reading bundled skin: {}", path.display()))?; + let skins_dir = self.skins_dir(); + let mut descriptor = skin::install_bytes(name, &bytes, &skins_dir)?; + skin::mark_bundled(&skins_dir, &mut descriptor)?; + self.mutate_persistent(|state| { + if state + .settings + .selected_skin + .as_deref() + .is_some_and(|id| obsolete_ids.contains(&id)) + { + state.settings.selected_skin = Some(descriptor.id.clone()); + } + })?; + for id in obsolete_ids { + if *id == descriptor.id { + continue; + } + for extension in ["wsz", "json"] { + let path = skins_dir.join(format!("{id}.{extension}")); + if let Err(error) = std::fs::remove_file(&path) + && error.kind() != std::io::ErrorKind::NotFound + { + log::warn!( + "Could not remove obsolete bundled skin {}: {error}", + path.display() + ); + } + } + } + Ok(descriptor) + } + pub async fn install_catalog_skin( &self, md5: &str, @@ -200,7 +276,37 @@ impl AppController { skin::read_bytes(&self.skins_dir(), id) } + pub fn list_skins(&self) -> Result> { + skin::list(&self.skins_dir()) + } + + pub fn delete_skin(&self, id: &str, app: &AppHandle) -> Result { + skin::remove(&self.skins_dir(), id)?; + let snapshot = self.mutate_persistent(|state| { + if state.settings.selected_skin.as_deref() == Some(id) { + state.settings.selected_skin = None; + } + })?; + emit_snapshot(app, &snapshot); + Ok(snapshot) + } + + pub fn select_installed_skin( + &self, + id: Option, + app: &AppHandle, + ) -> Result { + if let Some(id) = id.as_deref() { + skin::read_bytes(&self.skins_dir(), id)?; + } + let snapshot = self.mutate_persistent(|state| state.settings.selected_skin = id)?; + emit_snapshot(app, &snapshot); + Ok(snapshot) + } + pub fn player_command(&self, command: PlayerCommand, app: &AppHandle) -> Result { + let update_playlist = player_command_updates_playlist(&command); + let update_playback = player_command_updates_playback(&command); match command { PlayerCommand::Play => { let item = { @@ -212,14 +318,14 @@ impl AppController { }; if let Some(item) = item { if self.snapshot().playback.status == PlaybackStatus::Stopped { - self.audio.send(AudioCommand::Load(item))?; + self.send_audio(AudioCommand::Load(item), app)?; } else { - self.audio.send(AudioCommand::Play)?; + self.send_audio(AudioCommand::Play, app)?; } } } - PlayerCommand::Pause => self.audio.send(AudioCommand::Pause)?, - PlayerCommand::Stop => self.audio.send(AudioCommand::Stop)?, + PlayerCommand::Pause => self.send_audio(AudioCommand::Pause, app)?, + PlayerCommand::Stop => self.send_audio_if_initialized(AudioCommand::Stop)?, PlayerCommand::Load { item_id } => { let item = { let mut state = self.state.lock().expect("state lock poisoned"); @@ -227,11 +333,11 @@ impl AppController { state.queue.iter().find(|item| item.id == item_id).cloned() } .context("queue item does not exist")?; - self.audio.send(AudioCommand::Load(item))?; + self.send_audio(AudioCommand::Load(item), app)?; } PlayerCommand::Seek { position_ms } => { if self.snapshot().playback.capabilities.seekable { - self.audio.send(AudioCommand::Seek(position_ms))?; + self.send_audio(AudioCommand::Seek(position_ms), app)?; let mut state = self.state.lock().expect("state lock poisoned"); state.playback.position_ms = position_ms; } @@ -239,23 +345,23 @@ impl AppController { PlayerCommand::Next => self.load_relative(1)?, PlayerCommand::Previous => self.load_relative(-1)?, PlayerCommand::SetVolume { value } => { - self.audio.send(AudioCommand::SetVolume(value))?; + self.send_audio_if_initialized(AudioCommand::SetVolume(value))?; self.mutate_persistent(|state| state.settings.volume = value.clamp(0.0, 1.0))?; } PlayerCommand::SetBalance { value } => { - self.audio.send(AudioCommand::SetBalance(value))?; + self.send_audio_if_initialized(AudioCommand::SetBalance(value))?; self.mutate_persistent(|state| state.settings.balance = value.clamp(-1.0, 1.0))?; } PlayerCommand::SetEqEnabled { enabled } => { let snapshot = self.mutate_persistent(|state| state.settings.eq.enabled = enabled)?; - self.audio.send(AudioCommand::SetEq(snapshot.settings.eq))?; + self.send_audio_if_initialized(AudioCommand::SetEq(snapshot.settings.eq))?; } PlayerCommand::SetPreamp { value_db } => { let snapshot = self.mutate_persistent(|state| { state.settings.eq.preamp_db = value_db.clamp(-12.0, 12.0) })?; - self.audio.send(AudioCommand::SetEq(snapshot.settings.eq))?; + self.send_audio_if_initialized(AudioCommand::SetEq(snapshot.settings.eq))?; } PlayerCommand::SetEqBand { index, value_db } => { let snapshot = self.mutate_persistent(|state| { @@ -263,7 +369,7 @@ impl AppController { *band = value_db.clamp(-12.0, 12.0); } })?; - self.audio.send(AudioCommand::SetEq(snapshot.settings.eq))?; + self.send_audio_if_initialized(AudioCommand::SetEq(snapshot.settings.eq))?; } PlayerCommand::ToggleShuffle => { self.mutate_persistent(|state| state.settings.shuffle = !state.settings.shuffle)?; @@ -304,14 +410,16 @@ impl AppController { } let snapshot = self.mutate_persistent(|_| {})?; - emit_snapshot(app, &snapshot); + emit_snapshot_for(app, &snapshot, update_playlist, update_playback); self.update_system_media(&snapshot); Ok(snapshot) } pub fn handle_audio_event(&self, event: AudioEvent, app: &AppHandle) { let mut load_next = false; - let snapshot = { + let queue_changed = matches!(&event, AudioEvent::Loaded { .. }); + let media_changed = !matches!(&event, AudioEvent::Position(_) | AudioEvent::Spectrum(_)); + let (playback, queue) = { let mut state = self.state.lock().expect("state lock poisoned"); match event { AudioEvent::Loading => { @@ -370,20 +478,50 @@ impl AppController { } state.revision += 1; state.playback.revision = state.revision; - state.clone() + let queue = queue_changed.then(|| state.queue.clone()); + (state.playback.clone(), queue) }; - emit_snapshot(app, &snapshot); - self.update_system_media(&snapshot); + let _ = app.emit_to("main", "player://snapshot", &playback); + if let Some(queue) = queue { + let _ = app.emit_to("playlist", "queue://snapshot", &queue); + if self + .state + .lock() + .expect("state lock poisoned") + .layout + .combined + { + let _ = app.emit_to("main", "queue://snapshot", &queue); + } + } + if media_changed { + self.update_system_media(&self.snapshot()); + } if load_next && let Err(error) = self.advance_after_end() { log::warn!("Could not advance to next item: {error:#}"); } } pub fn emit_position(&self, app: &AppHandle) { - if self.snapshot().playback.status != PlaybackStatus::Playing { + if self + .state + .lock() + .expect("state lock poisoned") + .playback + .status + != PlaybackStatus::Playing + { return; } - self.handle_audio_event(AudioEvent::Position(self.audio.position_ms()), app); + let position = self + .audio + .lock() + .expect("audio lock poisoned") + .as_ref() + .map(AudioController::position_ms); + if let Some(position) = position { + self.handle_audio_event(AudioEvent::Position(position), app); + } } pub fn configure_windows(&self, app: &AppHandle) -> Result<()> { @@ -391,16 +529,7 @@ impl AppController { let main = app .get_webview_window("main") .context("main window missing")?; - let equalizer = app - .get_webview_window("equalizer") - .context("equalizer window missing")?; - let playlist = app - .get_webview_window("playlist") - .context("playlist window missing")?; - if wayland { - equalizer.hide()?; - playlist.hide()?; main.set_size(tauri::LogicalSize::new(275.0, 464.0))?; let snapshot = self.mutate_persistent(|state| state.layout.combined = true)?; emit_snapshot(app, &snapshot); @@ -410,20 +539,6 @@ impl AppController { snapshot.layout.main.x, snapshot.layout.main.y, ))?; - equalizer.set_position(tauri::LogicalPosition::new( - snapshot.layout.equalizer.x, - snapshot.layout.equalizer.y, - ))?; - playlist.set_position(tauri::LogicalPosition::new( - snapshot.layout.playlist.x, - snapshot.layout.playlist.y, - ))?; - if snapshot.layout.equalizer_visible { - equalizer.show()?; - } - if snapshot.layout.playlist_visible { - playlist.show()?; - } } let snapshot = self.snapshot(); self.apply_window_sizes(app, &snapshot)?; @@ -435,6 +550,26 @@ impl AppController { Ok(()) } + pub fn restore_visible_windows(&self, app: &AppHandle) -> Result<()> { + let snapshot = self.snapshot(); + if snapshot.layout.combined { + return Ok(()); + } + if snapshot.layout.equalizer_visible { + self.ensure_panel_window(app, "equalizer")?.show()?; + } + if snapshot.layout.playlist_visible { + self.ensure_panel_window(app, "playlist")?.show()?; + } + refresh_native_window_group( + app, + &snapshot.layout, + snapshot.settings.double_size, + snapshot.settings.main_winshade, + ); + Ok(()) + } + fn apply_window_sizes(&self, app: &AppHandle, snapshot: &AppSnapshot) -> Result<()> { let scale = if snapshot.settings.double_size { 2.0 @@ -481,14 +616,12 @@ impl AppController { return Err(anyhow!("unknown panel")); } if panel == "skins" { - let window = app - .get_webview_window(panel) - .context("skin browser window does not exist")?; if visible { + let window = self.ensure_panel_window(app, panel)?; window.show()?; window.set_focus()?; let _ = app.emit_to("skins", "skin-browser://opened", ()); - } else { + } else if let Some(window) = app.get_webview_window(panel) { window.hide()?; } return Ok(()); @@ -496,13 +629,11 @@ impl AppController { if self.snapshot().layout.combined { return Ok(()); } - let window = app - .get_webview_window(panel) - .with_context(|| format!("window '{panel}' does not exist"))?; if visible { + let window = self.ensure_panel_window(app, panel)?; window.show()?; window.set_focus()?; - } else { + } else if let Some(window) = app.get_webview_window(panel) { window.hide()?; } let snapshot = self.mutate_persistent(|state| match panel { @@ -512,6 +643,12 @@ impl AppController { })?; emit_snapshot(app, &snapshot); let _ = app.emit("layout://changed", &snapshot.layout); + refresh_native_window_group( + app, + &snapshot.layout, + snapshot.settings.double_size, + snapshot.settings.main_winshade, + ); Ok(()) } @@ -526,30 +663,29 @@ impl AppController { return; } let position = position.to_logical::(scale_factor); - let previous = self.snapshot(); - if previous.layout.combined { + let (previous, double_size, main_winshade) = { + let state = self.state.lock().expect("state lock poisoned"); + ( + state.layout.clone(), + state.settings.double_size, + state.settings.main_winshade, + ) + }; + if previous.combined { return; } let old = match label { - "main" => &previous.layout.main, - "equalizer" => &previous.layout.equalizer, - "playlist" => &previous.layout.playlist, + "main" => &previous.main, + "equalizer" => &previous.equalizer, + "playlist" => &previous.playlist, _ => return, }; if (old.x - position.x).abs() < 0.1 && (old.y - position.y).abs() < 0.1 { return; } - let scale = if previous.settings.double_size { - 2.0 - } else { - 1.0 - }; - let main_height = if previous.settings.main_winshade { - 14.0 - } else { - 116.0 - } * scale; + let scale = if double_size { 2.0 } else { 1.0 }; + let main_height = if main_winshade { 14.0 } else { 116.0 } * scale; let eq_height = 116.0 * scale; let width = 275.0 * scale; let mut target = crate::model::WindowPoint { @@ -560,24 +696,16 @@ impl AppController { let mut grouped_playlist = None; if label == "main" { - let delta_x = target.x - previous.layout.main.x; - let delta_y = target.y - previous.layout.main.y; - if attached_below( - &previous.layout.main, - main_height, - &previous.layout.equalizer, - ) { + let delta_x = target.x - previous.main.x; + let delta_y = target.y - previous.main.y; + if attached_below(&previous.main, main_height, &previous.equalizer) { grouped_eq = Some(crate::model::WindowPoint { - x: previous.layout.equalizer.x + delta_x, - y: previous.layout.equalizer.y + delta_y, + x: previous.equalizer.x + delta_x, + y: previous.equalizer.y + delta_y, }); } - let playlist_anchor = grouped_eq.as_ref().unwrap_or(&previous.layout.equalizer); - if attached_below( - &previous.layout.equalizer, - eq_height, - &previous.layout.playlist, - ) { + let playlist_anchor = grouped_eq.as_ref().unwrap_or(&previous.equalizer); + if attached_below(&previous.equalizer, eq_height, &previous.playlist) { grouped_playlist = Some(crate::model::WindowPoint { x: playlist_anchor.x, y: playlist_anchor.y + eq_height, @@ -586,23 +714,20 @@ impl AppController { } else { let anchors = if label == "equalizer" { vec![ - (&previous.layout.main, main_height), - ( - &previous.layout.playlist, - previous.layout.playlist_height * scale, - ), + (&previous.main, main_height), + (&previous.playlist, previous.playlist_height * scale), ] } else { vec![ - (&previous.layout.main, main_height), - (&previous.layout.equalizer, eq_height), + (&previous.main, main_height), + (&previous.equalizer, eq_height), ] }; target = snap_to_windows( target, width, if label == "playlist" { - previous.layout.playlist_height * scale + previous.playlist_height * scale } else { eq_height }, @@ -610,7 +735,8 @@ impl AppController { ); } - let snapshot = match self.mutate_persistent(|state| { + let layout = { + let mut state = self.state.lock().expect("state lock poisoned"); match label { "main" => state.layout.main = target, "equalizer" => state.layout.equalizer = target, @@ -623,13 +749,13 @@ impl AppController { if let Some(point) = grouped_playlist { state.layout.playlist = point; } - }) { - Ok(snapshot) => snapshot, - Err(error) => { - log::warn!("Could not save window position: {error:#}"); - return; - } + state.revision += 1; + state.playback.revision = state.revision; + state.layout.clone() }; + if let Err(error) = self.deferred_saver.schedule() { + log::warn!("Could not schedule window position save: {error:#}"); + } if ((target.x - position.x).abs() >= 0.1 || (target.y - position.y).abs() >= 0.1) && let Some(window) = app.get_webview_window(label) @@ -640,33 +766,46 @@ impl AppController { if let Some(point) = point && let Some(window) = app.get_webview_window(window_label) { + #[cfg(not(target_os = "macos"))] let _ = window.set_position(tauri::LogicalPosition::new(point.x, point.y)); + #[cfg(target_os = "macos")] + let _ = (window, point); } } - emit_snapshot(app, &snapshot); - let _ = app.emit("layout://changed", &snapshot.layout); + let _ = app.emit("layout://changed", &layout); + if label != "main" { + refresh_native_window_group(app, &layout, double_size, main_winshade); + } } pub fn handle_playlist_resized(&self, app: &AppHandle, height: u32, scale_factor: f64) { - let snapshot = self.snapshot(); - if snapshot.layout.combined { + let (combined, double_size, playlist_height) = { + let state = self.state.lock().expect("state lock poisoned"); + ( + state.layout.combined, + state.settings.double_size, + state.layout.playlist_height, + ) + }; + if combined { return; } - let interface_scale = if snapshot.settings.double_size { - 2.0 - } else { - 1.0 - }; + let interface_scale = if double_size { 2.0 } else { 1.0 }; let logical_height = f64::from(height) / scale_factor / interface_scale; - if (logical_height - snapshot.layout.playlist_height).abs() < 0.5 { + if (logical_height - playlist_height).abs() < 0.5 { return; } - if let Ok(snapshot) = - self.mutate_persistent(|state| state.layout.playlist_height = logical_height.max(116.0)) - { - emit_snapshot(app, &snapshot); - let _ = app.emit("layout://changed", &snapshot.layout); + let layout = { + let mut state = self.state.lock().expect("state lock poisoned"); + state.layout.playlist_height = logical_height.max(116.0); + state.revision += 1; + state.playback.revision = state.revision; + state.layout.clone() + }; + if let Err(error) = self.deferred_saver.schedule() { + log::warn!("Could not schedule playlist size save: {error:#}"); } + let _ = app.emit("layout://changed", &layout); } fn load_relative(&self, offset: isize) -> Result<()> { @@ -702,7 +841,7 @@ impl AppController { state.playback.current_item_id = Some(item.id); item }; - self.audio.send(AudioCommand::Load(item)) + self.send_audio_if_initialized(AudioCommand::Load(item)) } fn advance_after_end(&self) -> Result<()> { @@ -717,7 +856,7 @@ impl AppController { && current.is_some_and(|index| index + 1 >= state.queue.len()) }; if should_stop { - self.audio.send(AudioCommand::Stop) + self.send_audio_if_initialized(AudioCommand::Stop) } else { self.load_relative(1) } @@ -731,7 +870,7 @@ impl AppController { state.playback.revision = state.revision; state.clone() }; - persistence::save(&self.data_dir, &snapshot)?; + self.deferred_saver.schedule()?; Ok(snapshot) } @@ -739,6 +878,48 @@ impl AppController { self.data_dir.join("skins") } + fn ensure_panel_window(&self, app: &AppHandle, panel: &str) -> Result { + if let Some(window) = app.get_webview_window(panel) { + return Ok(window); + } + let snapshot = self.snapshot(); + let (title, resizable, transparent) = match panel { + "equalizer" => ("Tonelag Equalizer", false, true), + "playlist" => ("Tonelag Playlist", true, true), + "skins" => ("Tonelag Skin Browser", true, false), + _ => return Err(anyhow!("unknown panel")), + }; + let (width, height, min_height) = panel_window_dimensions(panel, &snapshot); + let mut builder = tauri::WebviewWindowBuilder::new( + app, + panel, + tauri::WebviewUrl::App(format!("index.html?panel={panel}").into()), + ) + .title(title) + .inner_size(width, height) + .min_inner_size(if panel == "skins" { 480.0 } else { width }, min_height) + .resizable(resizable) + .decorations(false) + .transparent(transparent) + .shadow(false) + .visible(false) + .skip_taskbar(true); + if panel == "skins" { + builder = builder.center(); + } + let window = builder.build()?; + if panel != "skins" { + let point = if panel == "equalizer" { + &snapshot.layout.equalizer + } else { + &snapshot.layout.playlist + }; + window.set_position(tauri::LogicalPosition::new(point.x, point.y))?; + window.set_always_on_top(snapshot.settings.always_on_top)?; + } + Ok(window) + } + fn update_system_media(&self, snapshot: &AppSnapshot) { if let Some(sender) = self .system_media @@ -751,15 +932,91 @@ impl AppController { } } +fn panel_window_dimensions(panel: &str, snapshot: &AppSnapshot) -> (f64, f64, f64) { + let scale = if snapshot.settings.double_size { + 2.0 + } else { + 1.0 + }; + match panel { + "equalizer" => (275.0 * scale, 116.0 * scale, 116.0 * scale), + "playlist" => ( + 275.0 * scale, + snapshot.layout.playlist_height.max(116.0) * scale, + 116.0 * scale, + ), + "skins" => (620.0, 520.0, 380.0), + _ => unreachable!("panel validated before computing dimensions"), + } +} + fn current_item(state: &AppSnapshot) -> Option<&QueueItem> { let id = state.playback.current_item_id?; state.queue.iter().find(|item| item.id == id) } fn emit_snapshot(app: &AppHandle, snapshot: &AppSnapshot) { - let _ = app.emit("app://snapshot", snapshot); - let _ = app.emit("player://snapshot", &snapshot.playback); - let _ = app.emit("queue://snapshot", &snapshot.queue); + emit_snapshot_for(app, snapshot, true, true); +} + +fn emit_snapshot_for( + app: &AppHandle, + snapshot: &AppSnapshot, + update_playlist: bool, + update_playback: bool, +) { + let lightweight = AppSnapshot { + revision: snapshot.revision, + queue: snapshot + .playback + .current_item_id + .and_then(|id| snapshot.queue.iter().find(|item| item.id == id)) + .cloned() + .into_iter() + .collect(), + playback: snapshot.playback.clone(), + settings: snapshot.settings.clone(), + layout: snapshot.layout.clone(), + }; + for label in ["equalizer", "skins"] { + let _ = app.emit_to(label, "app://snapshot", &lightweight); + } + if snapshot.layout.combined { + let _ = app.emit_to("main", "app://snapshot", snapshot); + } else { + let _ = app.emit_to("main", "app://snapshot", &lightweight); + if update_playlist { + let _ = app.emit_to("playlist", "app://snapshot", snapshot); + } + } + if update_playback { + let _ = app.emit_to("main", "player://snapshot", &snapshot.playback); + } +} + +fn player_command_updates_playlist(command: &PlayerCommand) -> bool { + matches!( + command, + PlayerCommand::Play + | PlayerCommand::Next + | PlayerCommand::Previous + | PlayerCommand::Load { .. } + | PlayerCommand::ToggleDoubleSize + | PlayerCommand::SetLanguage { .. } + ) +} + +fn player_command_updates_playback(command: &PlayerCommand) -> bool { + matches!( + command, + PlayerCommand::Play + | PlayerCommand::Pause + | PlayerCommand::Stop + | PlayerCommand::Next + | PlayerCommand::Previous + | PlayerCommand::Load { .. } + | PlayerCommand::Seek { .. } + ) } fn is_playlist(path: &Path) -> bool { @@ -820,6 +1077,103 @@ fn is_supported_audio(path: &Path) -> bool { const SNAP_DISTANCE: f64 = 10.0; +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum PlaylistParent { + Main, + Equalizer, +} + +fn native_group_edges( + layout: &crate::model::WindowLayout, + double_size: bool, + main_winshade: bool, +) -> (bool, Option) { + let scale = if double_size { 2.0 } else { 1.0 }; + let main_height = if main_winshade { 14.0 } else { 116.0 } * scale; + let equalizer_height = 116.0 * scale; + let equalizer_to_main = + layout.equalizer_visible && attached_below(&layout.main, main_height, &layout.equalizer); + let playlist_parent = if !layout.playlist_visible { + None + } else if layout.equalizer_visible + && attached_below(&layout.equalizer, equalizer_height, &layout.playlist) + { + Some(PlaylistParent::Equalizer) + } else if attached_below(&layout.main, main_height, &layout.playlist) { + Some(PlaylistParent::Main) + } else { + None + }; + (equalizer_to_main, playlist_parent) +} + +#[cfg(target_os = "macos")] +fn refresh_native_window_group( + app: &AppHandle, + layout: &crate::model::WindowLayout, + double_size: bool, + main_winshade: bool, +) { + use objc2_app_kit::{NSWindow, NSWindowOrderingMode}; + + let Some(main) = app.get_webview_window("main") else { + return; + }; + let Some(equalizer) = app.get_webview_window("equalizer") else { + return; + }; + let Some(playlist) = app.get_webview_window("playlist") else { + return; + }; + let Ok(main_pointer) = main.ns_window() else { + return; + }; + let Ok(equalizer_pointer) = equalizer.ns_window() else { + return; + }; + let Ok(playlist_pointer) = playlist.ns_window() else { + return; + }; + let main_pointer = main_pointer as usize; + let equalizer_pointer = equalizer_pointer as usize; + let playlist_pointer = playlist_pointer as usize; + let (equalizer_to_main, playlist_parent) = + native_group_edges(layout, double_size, main_winshade); + let _ = app.run_on_main_thread(move || unsafe { + let main = &*(main_pointer as *const NSWindow); + let equalizer = &*(equalizer_pointer as *const NSWindow); + let playlist = &*(playlist_pointer as *const NSWindow); + + if let Some(parent) = equalizer.parentWindow() { + parent.removeChildWindow(equalizer); + } + if let Some(parent) = playlist.parentWindow() { + parent.removeChildWindow(playlist); + } + if equalizer_to_main { + main.addChildWindow_ordered(equalizer, NSWindowOrderingMode::Above); + } + match playlist_parent { + Some(PlaylistParent::Main) => { + main.addChildWindow_ordered(playlist, NSWindowOrderingMode::Above) + } + Some(PlaylistParent::Equalizer) => { + equalizer.addChildWindow_ordered(playlist, NSWindowOrderingMode::Above) + } + None => {} + } + }); +} + +#[cfg(not(target_os = "macos"))] +fn refresh_native_window_group( + _app: &AppHandle, + _layout: &crate::model::WindowLayout, + _double_size: bool, + _main_winshade: bool, +) { +} + fn attached_below( top: &crate::model::WindowPoint, top_height: f64, @@ -879,4 +1233,57 @@ mod window_tests { assert!(has_extension(Path::new("theme.WSZ"), "wsz")); assert!(has_extension(Path::new("preset.EQF"), "eqf")); } + + #[test] + fn builds_native_chain_for_a_classic_three_window_stack() { + let layout = crate::model::WindowLayout::default(); + assert_eq!( + native_group_edges(&layout, false, false), + (true, Some(PlaylistParent::Equalizer)) + ); + } + + #[test] + fn lazy_panel_dimensions_restore_double_size() { + let mut snapshot = AppSnapshot::default(); + snapshot.settings.double_size = true; + snapshot.layout.playlist_height = 232.0; + + assert_eq!( + panel_window_dimensions("equalizer", &snapshot), + (550.0, 232.0, 232.0) + ); + assert_eq!( + panel_window_dimensions("playlist", &snapshot), + (550.0, 464.0, 232.0) + ); + assert_eq!( + panel_window_dimensions("skins", &snapshot), + (620.0, 520.0, 380.0) + ); + } + + #[test] + fn equalizer_commands_do_not_refresh_the_playlist_window() { + assert!(!player_command_updates_playlist( + &PlayerCommand::SetEqBand { + index: 3, + value_db: 4.0, + } + )); + assert!(!player_command_updates_playlist( + &PlayerCommand::SetPreamp { value_db: -2.0 } + )); + assert!(!player_command_updates_playback( + &PlayerCommand::SetPreamp { value_db: -2.0 } + )); + assert!(player_command_updates_playlist(&PlayerCommand::Play)); + assert!(player_command_updates_playback(&PlayerCommand::Play)); + assert!(player_command_updates_playlist( + &PlayerCommand::ToggleDoubleSize + )); + assert!(!player_command_updates_playback( + &PlayerCommand::ToggleDoubleSize + )); + } } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index cc56c04..8e84f21 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -24,8 +24,11 @@ use uuid::Uuid; type CommandResult = Result; #[tauri::command] -fn get_snapshot(controller: State<'_, Arc>) -> AppSnapshot { - controller.snapshot() +fn get_snapshot( + window: tauri::WebviewWindow, + controller: State<'_, Arc>, +) -> AppSnapshot { + snapshot_for_window(controller.snapshot(), window.label()) } #[tauri::command] @@ -33,9 +36,11 @@ fn player_command( command: PlayerCommand, controller: State<'_, Arc>, app: AppHandle, + window: tauri::WebviewWindow, ) -> CommandResult { controller .player_command(command, &app) + .map(|snapshot| snapshot_for_window(snapshot, window.label())) .map_err(format_error) } @@ -97,9 +102,11 @@ fn eqf_import( path: String, controller: State<'_, Arc>, app: AppHandle, + window: tauri::WebviewWindow, ) -> CommandResult { controller .import_eqf(PathBuf::from(path).as_path(), &app) + .map(|snapshot| snapshot_for_window(snapshot, window.label())) .map_err(format_error) } @@ -126,6 +133,37 @@ fn skin_bytes(id: String, controller: State<'_, Arc>) -> CommandR controller.skin_bytes(&id).map_err(format_error) } +#[tauri::command] +fn skin_list(controller: State<'_, Arc>) -> CommandResult> { + controller.list_skins().map_err(format_error) +} + +#[tauri::command] +fn skin_delete( + id: String, + controller: State<'_, Arc>, + app: AppHandle, + window: tauri::WebviewWindow, +) -> CommandResult { + controller + .delete_skin(&id, &app) + .map(|snapshot| snapshot_for_window(snapshot, window.label())) + .map_err(format_error) +} + +#[tauri::command] +fn skin_select( + id: Option, + controller: State<'_, Arc>, + app: AppHandle, + window: tauri::WebviewWindow, +) -> CommandResult { + controller + .select_installed_skin(id, &app) + .map(|snapshot| snapshot_for_window(snapshot, window.label())) + .map_err(format_error) +} + #[tauri::command] async fn skin_catalog_browse( query: Option, @@ -167,6 +205,28 @@ fn set_panel_visible( .map_err(format_error) } +#[tauri::command] +fn frontend_ready(app: AppHandle, controller: State<'_, Arc>) -> CommandResult<()> { + controller + .restore_visible_windows(&app) + .map_err(format_error)?; + let audio_controller = controller.inner().clone(); + let audio_app = app.clone(); + tauri::async_runtime::spawn_blocking(move || { + if let Err(error) = audio_controller.initialize_audio(&audio_app) { + log::warn!("Could not initialize audio after startup: {error:#}"); + } + }); + Ok(()) +} + +#[tauri::command] +fn quit_app(app: AppHandle, controller: State<'_, Arc>) -> CommandResult<()> { + controller.save_before_exit().map_err(format_error)?; + app.exit(0); + Ok(()) +} + pub fn run() { tauri::Builder::default() .plugin(tauri_plugin_dialog::init()) @@ -219,8 +279,21 @@ pub fn run() { }) .setup(|app| { let data_dir = app.path().app_data_dir()?; - let (controller, mut audio_events) = AppController::new(data_dir)?; - let controller = Arc::new(controller); + let controller = Arc::new(AppController::new(data_dir)?); + let bundled_skin = app + .path() + .resource_dir()? + .join("default-skin") + .join("pastellplate.wsz"); + if bundled_skin.exists() + && let Err(error) = controller.install_bundled_skin( + &bundled_skin, + "Pastellplate", + &["3d4b15657d4352bef0706b4db9010edc5142fef1486069ef2cab0ebef57cce22"], + ) + { + log::warn!("Could not install bundled Pastellplate skin: {error:#}"); + } app.manage(controller.clone()); controller.configure_windows(app.handle())?; let initial_paths = std::env::args() @@ -272,14 +345,6 @@ pub fn run() { } })?; - let app_handle = app.handle().clone(); - let event_controller = controller.clone(); - tauri::async_runtime::spawn(async move { - while let Some(event) = audio_events.recv().await { - event_controller.handle_audio_event(event, &app_handle); - } - }); - let app_handle = app.handle().clone(); tauri::async_runtime::spawn(async move { let mut ticker = tokio::time::interval(Duration::from_millis(100)); @@ -304,10 +369,15 @@ pub fn run() { eqf_export, skin_import, skin_bytes, + skin_list, + skin_delete, + skin_select, skin_catalog_browse, skin_catalog_install, resolve_stream, set_panel_visible, + frontend_ready, + quit_app, ]) .run(tauri::generate_context!()) .expect("error while running Tonelag"); @@ -316,3 +386,11 @@ pub fn run() { fn format_error(error: impl std::fmt::Display) -> String { error.to_string() } + +fn snapshot_for_window(mut snapshot: AppSnapshot, label: &str) -> AppSnapshot { + if label != "playlist" && !(label == "main" && snapshot.layout.combined) { + let current = snapshot.playback.current_item_id; + snapshot.queue.retain(|item| Some(item.id) == current); + } + snapshot +} diff --git a/src-tauri/src/persistence.rs b/src-tauri/src/persistence.rs index 63af9cb..228b35f 100644 --- a/src-tauri/src/persistence.rs +++ b/src-tauri/src/persistence.rs @@ -1,4 +1,10 @@ -use std::{fs, io::Write, path::Path}; +use std::{ + fs, + io::Write, + path::{Path, PathBuf}, + sync::{Arc, Mutex, mpsc}, + time::Duration, +}; use anyhow::{Context, Result}; @@ -6,6 +12,45 @@ use crate::model::AppSnapshot; const SESSION_FILE: &str = "session.json"; +pub struct DeferredSaver { + sender: mpsc::Sender<()>, +} + +impl DeferredSaver { + pub fn new(data_dir: PathBuf, state: Arc>) -> Result { + Self::spawn(Duration::from_millis(250), move || { + let snapshot = state.lock().expect("state lock poisoned").clone(); + if let Err(error) = save(&data_dir, &snapshot) { + log::warn!("Could not save deferred session state: {error:#}"); + } + }) + } + + fn spawn(delay: Duration, callback: impl Fn() + Send + 'static) -> Result { + let (sender, receiver) = mpsc::channel(); + std::thread::Builder::new() + .name("tonelag-session-saver".into()) + .spawn(move || { + while receiver.recv().is_ok() { + while receiver.recv_timeout(delay).is_ok() {} + callback(); + } + })?; + Ok(Self { sender }) + } + + pub fn schedule(&self) -> Result<()> { + self.sender + .send(()) + .context("deferred session saver stopped") + } + + #[cfg(test)] + fn with_callback(delay: Duration, callback: impl Fn() + Send + 'static) -> Self { + Self::spawn(delay, callback).unwrap() + } +} + pub fn load(data_dir: &Path) -> Result { let path = data_dir.join(SESSION_FILE); if !path.exists() { @@ -29,7 +74,7 @@ pub fn save(data_dir: &Path, snapshot: &AppSnapshot) -> Result<()> { .with_context(|| format!("failed creating {}", data_dir.display()))?; let target = data_dir.join(SESSION_FILE); let temp = data_dir.join(format!(".{SESSION_FILE}.tmp")); - let bytes = serde_json::to_vec_pretty(snapshot)?; + let bytes = serde_json::to_vec(snapshot)?; let mut file = fs::File::create(&temp).with_context(|| format!("failed creating {}", temp.display()))?; @@ -41,6 +86,8 @@ pub fn save(data_dir: &Path, snapshot: &AppSnapshot) -> Result<()> { #[cfg(test)] mod tests { + use std::{sync::mpsc, time::Duration}; + use super::*; #[test] @@ -58,4 +105,21 @@ mod tests { ); assert_eq!(loaded.playback.position_ms, 0); } + + #[test] + fn deferred_saver_coalesces_a_burst_to_the_latest_snapshot() { + let (saved_tx, saved_rx) = mpsc::channel(); + let revision = Arc::new(Mutex::new(0)); + let callback_revision = revision.clone(); + let saver = DeferredSaver::with_callback(Duration::from_millis(20), move || { + saved_tx.send(*callback_revision.lock().unwrap()).unwrap() + }); + for next_revision in 1..=20 { + *revision.lock().unwrap() = next_revision; + saver.schedule().unwrap(); + } + + assert_eq!(saved_rx.recv_timeout(Duration::from_secs(1)).unwrap(), 20); + assert!(saved_rx.recv_timeout(Duration::from_millis(60)).is_err()); + } } diff --git a/src-tauri/src/skin.rs b/src-tauri/src/skin.rs index 6d7460a..3c4bdd8 100644 --- a/src-tauri/src/skin.rs +++ b/src-tauri/src/skin.rs @@ -22,6 +22,8 @@ pub struct SkinDescriptor { pub id: String, pub name: String, pub files: Vec, + #[serde(default)] + pub bundled: bool, } pub fn import(source: &Path, skins_dir: &Path) -> Result { @@ -80,7 +82,7 @@ pub fn install_bytes(name: &str, bytes: &[u8], skins_dir: &Path) -> Result(); let name = name.trim(); - Ok(SkinDescriptor { + let descriptor = SkinDescriptor { id, name: if name.is_empty() { "Imported skin".to_owned() @@ -88,7 +90,49 @@ pub fn install_bytes(name: &str, bytes: &[u8], skins_dir: &Path) -> Result Result> { + if !skins_dir.exists() { + return Ok(Vec::new()); + } + let mut descriptors = Vec::new(); + for entry in fs::read_dir(skins_dir)? { + let path = entry?.path(); + if path.extension().and_then(|value| value.to_str()) != Some("wsz") { + continue; + } + let Some(id) = path.file_stem().and_then(|value| value.to_str()) else { + continue; + }; + if id.len() != 64 || !id.bytes().all(|byte| byte.is_ascii_hexdigit()) { + continue; + } + let descriptor_path = skins_dir.join(format!("{id}.json")); + let descriptor = fs::read(&descriptor_path) + .ok() + .and_then(|bytes| serde_json::from_slice::(&bytes).ok()) + .filter(|descriptor| descriptor.id == id) + .or_else(|| { + let bytes = fs::read(&path).ok()?; + let (_, files) = inspect(&bytes).ok()?; + Some(SkinDescriptor { + id: id.to_owned(), + name: format!("Legacy skin {}", &id[..8]), + files, + bundled: false, + }) + }); + if let Some(descriptor) = descriptor { + descriptors.push(descriptor); + } + } + descriptors.sort_by_cached_key(|descriptor| descriptor.name.to_lowercase()); + Ok(descriptors) } pub fn read_bytes(skins_dir: &Path, id: &str) -> Result> { @@ -98,6 +142,71 @@ pub fn read_bytes(skins_dir: &Path, id: &str) -> Result> { fs::read(skins_dir.join(format!("{id}.wsz"))).context("failed reading imported skin") } +pub fn mark_bundled(skins_dir: &Path, descriptor: &mut SkinDescriptor) -> Result<()> { + descriptor.bundled = true; + write_descriptor(skins_dir, descriptor) +} + +pub fn remove(skins_dir: &Path, id: &str) -> Result<()> { + if id.len() != 64 || !id.bytes().all(|byte| byte.is_ascii_hexdigit()) { + bail!("invalid skin id"); + } + let archive = skins_dir.join(format!("{id}.wsz")); + let metadata = skins_dir.join(format!("{id}.json")); + let descriptor = fs::read(&metadata) + .ok() + .and_then(|bytes| serde_json::from_slice::(&bytes).ok()); + if descriptor.is_some_and(|descriptor| descriptor.bundled) { + bail!("bundled skins cannot be deleted"); + } + if !archive.exists() { + bail!("skin is not installed"); + } + + let nonce = uuid::Uuid::new_v4(); + let archived = skins_dir.join(format!(".deleted-{nonce}.wsz")); + let metadata_archived = skins_dir.join(format!(".deleted-{nonce}.json")); + fs::rename(&archive, &archived).context("failed staging skin archive for deletion")?; + if metadata.exists() + && let Err(error) = fs::rename(&metadata, &metadata_archived) + { + let _ = fs::rename(&archived, &archive); + return Err(error).context("failed staging skin metadata for deletion"); + } + fs::remove_file(&archived).context("failed deleting skin archive")?; + if metadata_archived.exists() { + fs::remove_file(metadata_archived).context("failed deleting skin metadata")?; + } + Ok(()) +} + +fn write_descriptor(skins_dir: &Path, descriptor: &SkinDescriptor) -> Result<()> { + let target = skins_dir.join(format!("{}.json", descriptor.id)); + if fs::read(&target) + .ok() + .and_then(|bytes| serde_json::from_slice::(&bytes).ok()) + .as_ref() + == Some(descriptor) + { + return Ok(()); + } + let temporary = skins_dir.join(format!(".skin-meta-{}.tmp", uuid::Uuid::new_v4())); + let result = (|| -> Result<()> { + let mut file = OpenOptions::new() + .write(true) + .create_new(true) + .open(&temporary)?; + file.write_all(&serde_json::to_vec_pretty(descriptor)?)?; + file.sync_all()?; + fs::rename(&temporary, &target)?; + Ok(()) + })(); + if result.is_err() { + let _ = fs::remove_file(&temporary); + } + result +} + fn validate_extension(path: &Path) -> Result<()> { let extension = path .extension() @@ -211,6 +320,77 @@ mod tests { .count(), 0 ); + assert_eq!(list(dir.path()).unwrap(), vec![descriptor]); + } + + #[test] + fn lists_existing_archives_without_metadata() { + let dir = tempfile::tempdir().unwrap(); + let source = dir.path().join("legacy.wsz"); + create_skin(&source, &["MAIN.BMP"]); + let bytes = fs::read(&source).unwrap(); + let (id, _) = inspect(&bytes).unwrap(); + fs::rename(source, dir.path().join(format!("{id}.wsz"))).unwrap(); + + let skins = list(dir.path()).unwrap(); + + assert_eq!(skins.len(), 1); + assert_eq!(skins[0].id, id); + assert_eq!(skins[0].name, format!("Legacy skin {}", &id[..8])); + } + + #[test] + fn removes_an_installed_skin_and_its_metadata() { + let dir = tempfile::tempdir().unwrap(); + let source = dir.path().join("remove.wsz"); + create_skin(&source, &["MAIN.BMP"]); + let bytes = fs::read(source).unwrap(); + let descriptor = install_bytes("Remove me", &bytes, dir.path()).unwrap(); + + remove(dir.path(), &descriptor.id).unwrap(); + + assert!(!dir.path().join(format!("{}.wsz", descriptor.id)).exists()); + assert!(!dir.path().join(format!("{}.json", descriptor.id)).exists()); + assert!(list(dir.path()).unwrap().is_empty()); + } + + #[test] + fn refuses_to_remove_a_bundled_skin() { + let dir = tempfile::tempdir().unwrap(); + let source = dir.path().join("bundled.wsz"); + create_skin(&source, &["MAIN.BMP"]); + let bytes = fs::read(source).unwrap(); + let mut descriptor = install_bytes("Bundled", &bytes, dir.path()).unwrap(); + mark_bundled(dir.path(), &mut descriptor).unwrap(); + + assert!(remove(dir.path(), &descriptor.id).is_err()); + assert!(dir.path().join(format!("{}.wsz", descriptor.id)).exists()); + } + + #[test] + fn bundled_pastellplate_is_a_complete_classic_skin() { + let bytes = include_bytes!("../../assets/default-skin/pastellplate.wsz"); + let (_, files) = inspect(bytes).unwrap(); + + for required in [ + "main.bmp", + "eqmain.bmp", + "pledit.bmp", + "cbuttons.bmp", + "titlebar.bmp", + "shufrep.bmp", + "posbar.bmp", + "text.bmp", + "numbers.bmp", + "pledit.txt", + "viscolor.txt", + "region.txt", + ] { + assert!( + files.iter().any(|name| leaf(name) == required), + "missing {required}" + ); + } } #[test] diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 1853083..ceb7a1c 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -28,63 +28,26 @@ "transparent": true, "shadow": false, "center": true - }, - { - "label": "equalizer", - "title": "Tonelag Equalizer", - "url": "index.html?panel=equalizer", - "width": 275, - "height": 116, - "minWidth": 275, - "minHeight": 116, - "resizable": false, - "decorations": false, - "transparent": true, - "shadow": false, - "visible": false, - "skipTaskbar": true - }, - { - "label": "playlist", - "title": "Tonelag Playlist", - "url": "index.html?panel=playlist", - "width": 275, - "height": 232, - "minWidth": 275, - "minHeight": 116, - "resizable": true, - "decorations": false, - "transparent": true, - "shadow": false, - "visible": false, - "skipTaskbar": true - }, - { - "label": "skins", - "title": "Tonelag Skin Browser", - "url": "index.html?panel=skins", - "width": 760, - "height": 620, - "minWidth": 520, - "minHeight": 420, - "resizable": true, - "decorations": true, - "transparent": false, - "visible": false, - "skipTaskbar": true, - "center": true } ] }, "bundle": { "active": true, "targets": "all", + "icon": [ + "icons/32x32.png", + "icons/128x128.png", + "icons/128x128@2x.png", + "icons/icon.icns", + "icons/icon.ico" + ], "category": "Music", "shortDescription": "Classic skin-compatible audio player", "longDescription": "An open-source desktop audio player compatible with Winamp Classic skin files.", "copyright": "Copyright 2026 Tonelag contributors", "resources": { "../assets/default-skin/model-275.wsz": "default-skin/model-275.wsz", + "../assets/default-skin/pastellplate.wsz": "default-skin/pastellplate.wsz", "../THIRD_PARTY_NOTICES.md": "THIRD_PARTY_NOTICES.md" }, "fileAssociations": [ diff --git a/src/App.tsx b/src/App.tsx index cbe6a15..8f9a964 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -4,10 +4,10 @@ import { MainPanel } from "./components/MainPanel"; import { EqualizerPanel } from "./components/EqualizerPanel"; import { PlaylistPanel } from "./components/PlaylistPanel"; import { SkinBrowserPanel } from "./components/SkinBrowserPanel"; -import { addPaths, isTauri, onDroppedPaths } from "./lib/backend"; +import { addPaths, isTauri, notifyFrontendReady, onDroppedPaths, setInterfaceScale } from "./lib/backend"; import { reportError } from "./lib/actions"; import { loadSkinById, useClassicSkin } from "./lib/skin-store"; -import { skinCssVariables } from "./lib/skin"; +import { skinCssClasses, skinCssVariables } from "./lib/skin"; import { acceptSnapshot, useAppSnapshot } from "./lib/store"; type Panel = "main" | "equalizer" | "playlist" | "combined" | "skins"; @@ -23,6 +23,10 @@ export default function App() { const [error, setError] = useState(null); const panel = snapshot.layout.combined && requestedPanel() === "main" ? "combined" : requestedPanel(); + useEffect(() => { + if (panel === "main" && isTauri()) void notifyFrontendReady().catch(reportError); + }, [panel]); + useEffect(() => { void i18n.changeLanguage(snapshot.settings.language); }, [snapshot.settings.language]); @@ -31,6 +35,12 @@ export default function App() { void loadSkinById(snapshot.settings.selectedSkin).catch(reportError); }, [snapshot.settings.selectedSkin]); + useEffect(() => { + if (!isTauri()) return; + const scale = panel !== "skins" && snapshot.settings.doubleSize ? 2 : 1; + void setInterfaceScale(scale).catch(reportError); + }, [panel, snapshot.settings.doubleSize]); + useEffect(() => { const handler = (event: Event) => { setError((event as CustomEvent).detail); @@ -56,7 +66,7 @@ export default function App() { }, []); return ( -
+
{(panel === "main" || panel === "combined") && } {(panel === "equalizer" || panel === "combined") && } {(panel === "playlist" || panel === "combined") && } diff --git a/src/bindings/generated/SkinDescriptor.ts b/src/bindings/generated/SkinDescriptor.ts index 24c7fec..65b4077 100644 --- a/src/bindings/generated/SkinDescriptor.ts +++ b/src/bindings/generated/SkinDescriptor.ts @@ -1,3 +1,3 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type SkinDescriptor = { id: string, name: string, files: Array, }; +export type SkinDescriptor = { id: string, name: string, files: Array, bundled: boolean, }; diff --git a/src/components/EqualizerPanel.tsx b/src/components/EqualizerPanel.tsx index 7064979..39a3fcf 100644 --- a/src/components/EqualizerPanel.tsx +++ b/src/components/EqualizerPanel.tsx @@ -13,32 +13,33 @@ export function EqualizerPanel({ snapshot }: { snapshot: AppSnapshot }) { void setPanelVisible("equalizer", false)}>×} >
- - - + +
- player({ type: "setPreamp", valueDb: value })} /> + player({ type: "setPreamp", valueDb: value })} /> {eq.bandsDb.map((value, index) => ( - player({ type: "setEqBand", index, valueDb })} /> + player({ type: "setEqBand", index, valueDb })} /> ))}
); } -function EqSlider({ label, value, onChange }: { label: string; value: number; onChange(value: number): void }) { +function EqSlider({ label, left, value, onChange }: { label: string; left: number; value: number; onChange(value: number): void }) { return ( -