diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 82af9d6..f1e1017 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,8 +1,11 @@ -# Builds the Windows installer and publishes a draft GitHub Release +# Builds the desktop installers and publishes a draft GitHub Release # whenever a `v*` tag is pushed. Publish flow: # git tag v0.1.0 && git push origin v0.1.0 # → wait for this workflow → review the draft on the Releases page → publish. # +# Windows produces the NSIS installer; Linux produces .deb, .rpm and an +# AppImage (see src-tauri/tauri.linux.conf.json for the bundle targets). +# # The TAURI_SIGNING_* secrets are for tauri-plugin-updater artifact # signatures (latest.json + .sig). Until the updater is wired up and the # secrets are set, they resolve to empty strings and the build simply @@ -16,11 +19,29 @@ on: jobs: build: - runs-on: windows-latest permissions: contents: write + strategy: + fail-fast: false + matrix: + include: + - platform: windows-latest + args: "" + - platform: ubuntu-22.04 + args: "" + runs-on: ${{ matrix.platform }} steps: - uses: actions/checkout@v4 + - name: Install Linux system dependencies + if: matrix.platform == 'ubuntu-22.04' + run: | + sudo apt-get update + sudo apt-get install -y \ + libwebkit2gtk-4.1-dev \ + libappindicator3-dev \ + librsvg2-dev \ + patchelf \ + libgtk-3-dev - uses: pnpm/action-setup@v4 - uses: actions/setup-node@v4 with: @@ -42,3 +63,4 @@ jobs: releaseBody: "See the assets below to download and install this version." releaseDraft: true prerelease: false + args: ${{ matrix.args }} diff --git a/README.md b/README.md index aa2ccd2..6a51b16 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ [![License: GPL-3.0](https://img.shields.io/badge/License-GPLv3-blue.svg)](LICENSE) [![Ko-fi](https://img.shields.io/badge/Ko--fi-Support%20the%20project-FF5E5B?logo=ko-fi&logoColor=white)](https://ko-fi.com/nuberr) -A fast, responsive YouTube Music desktop client for Windows. +A fast, responsive YouTube Music desktop client for Windows and Linux. Built as a reaction to the sluggish webview-wrapper experience — YTubic talks to YouTube's InnerTube API directly, renders its own UI, and caches aggressively, so navigation and playback feel instant. @@ -19,7 +19,7 @@ Built as a reaction to the sluggish webview-wrapper experience — YTubic talks - **Synced lyrics** — line-by-line synced lyrics from multiple providers (LRCLIB, Musixmatch, Genius) - **Hi-res cover art** — upgrades album covers to high-resolution studio art when available - **Full library support** — your playlists, likes, albums and artists; search with filters; radio/autoplay queues -- **Windows integration** — media keys, System Media Transport Controls, tray icon, single instance +- **Desktop integration** — media keys and Now Playing controls (SMTC on Windows, MPRIS on Linux), tray icon, single instance - **Auto-updates** — the app updates itself from GitHub Releases, and keeps its yt-dlp copy fresh automatically > **Disclaimer:** YTubic is an unofficial client. It is not affiliated with, @@ -32,7 +32,14 @@ Built as a reaction to the sluggish webview-wrapper experience — YTubic talks Download the latest installer from the [Releases](../../releases) page and run it. -- **Windows 10/11 only** for now. +- **Windows 10/11**: run the `.exe` NSIS installer. +- **Linux**: install the `.deb` (Debian/Ubuntu), `.rpm` (Fedora/openSUSE), or + run the `AppImage` directly. Requires WebKitGTK 4.1 + GTK 3, plus the + GStreamer "good" and "base" plugins (and `gst-libav`) — WebKitGTK decodes + audio through GStreamer, so without `autoaudiosink` + the WebM/Opus/AAC + demuxers there is no sound. These are pulled in automatically by the + `.deb`/`.rpm`; for the AppImage install them yourself, e.g. on Arch: + `sudo pacman -S webkit2gtk-4.1 gst-plugins-base gst-plugins-good gst-libav`. - On first launch the app downloads its own copy of yt-dlp (~12 MB) into its data folder and keeps it updated automatically. - Signing in is optional: browse and playback work anonymously; sign in to get diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index a013ac7..2707f19 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -33,6 +33,7 @@ "core:window:allow-toggle-maximize", "core:window:allow-close", "core:window:allow-start-dragging", + "core:window:allow-start-resize-dragging", "core:window:allow-outer-position", "core:window:allow-outer-size", "core:window:allow-set-always-on-top" diff --git a/src-tauri/tauri.linux.conf.json b/src-tauri/tauri.linux.conf.json new file mode 100644 index 0000000..ae889ab --- /dev/null +++ b/src-tauri/tauri.linux.conf.json @@ -0,0 +1,26 @@ +{ + "$schema": "https://schema.tauri.app/config/2", + "bundle": { + "targets": ["deb", "rpm", "appimage"], + "category": "Music", + "linux": { + "deb": { + "depends": [ + "libwebkit2gtk-4.1-0", + "libgtk-3-0", + "gstreamer1.0-plugins-base", + "gstreamer1.0-plugins-good", + "gstreamer1.0-libav" + ] + }, + "rpm": { + "depends": [ + "webkit2gtk4.1", + "gtk3", + "gstreamer1-plugins-base", + "gstreamer1-plugins-good" + ] + } + } + } +} diff --git a/src/components/layout/app-shell.tsx b/src/components/layout/app-shell.tsx index 5ce0a64..c94029e 100644 --- a/src/components/layout/app-shell.tsx +++ b/src/components/layout/app-shell.tsx @@ -10,6 +10,7 @@ import { PlayerBar } from "@/components/layout/player-bar"; import { PlayerBarBottom } from "@/components/layout/player-bar-bottom"; import { FloatingPlayerSync } from "@/components/layout/floating-player-sync"; import { DragSnapOverlay } from "@/components/layout/drag-snap-overlay"; +import { WindowResizeHandles } from "@/components/layout/window-resize-handles"; import { EntityPageHeader } from "@/components/layout/entity-page-header"; import { SettingsDialog } from "@/components/settings/settings-dialog"; import { PremiumGateDialog } from "@/components/layout/premium-gate-dialog"; @@ -234,6 +235,7 @@ export function AppShell({ children }: { children: ReactNode }) { {mode === "floating" && hasTrack && } + diff --git a/src/components/layout/floating-player-app.tsx b/src/components/layout/floating-player-app.tsx index ebe4b51..f01627d 100644 --- a/src/components/layout/floating-player-app.tsx +++ b/src/components/layout/floating-player-app.tsx @@ -8,6 +8,7 @@ import { TooltipProvider } from "@/components/ui/tooltip"; import { Toaster } from "@/components/ui/sonner"; import { PlayerBar } from "@/components/layout/player-bar"; import { FloatingPlayerSyncReceiver } from "@/components/layout/floating-player-sync"; +import { WindowResizeHandles } from "@/components/layout/window-resize-handles"; import { pickHighResThumbnail } from "@/components/shared/thumbnail"; import { usePlaybackStore, @@ -64,6 +65,7 @@ export default function FloatingPlayerApp() {
+ diff --git a/src/components/layout/window-resize-handles.tsx b/src/components/layout/window-resize-handles.tsx new file mode 100644 index 0000000..2781231 --- /dev/null +++ b/src/components/layout/window-resize-handles.tsx @@ -0,0 +1,74 @@ +import { getCurrentWindow } from "@tauri-apps/api/window"; + +// `@tauri-apps/api/window` declares `ResizeDirection` but doesn't export +// it, so we mirror the union here. Values must match the API exactly. +type ResizeDirection = + | "North" + | "South" + | "East" + | "West" + | "NorthEast" + | "NorthWest" + | "SouthEast" + | "SouthWest"; + +/** + * Edge + corner grips that drive native window resizing on frameless + * windows. + * + * With `decorations: false`, Windows' WebView2 still exposes an + * invisible native resize border, so the window resizes out of the box + * there. WebKitGTK (Linux) and WKWebView (macOS) do NOT — a borderless + * window is stuck at its size unless the page grabs the edge and calls + * `startResizeDragging` itself. These transparent strips do exactly + * that. + * + * We render them everywhere except Windows: on Windows the native + * border already works, and overlaying our own grips there would only + * risk fighting it. Each grip is a few pixels along one edge (or corner) + * so it never eats clicks meant for real content. + */ +const HANDLES: { dir: ResizeDirection; className: string }[] = [ + // Edges + { dir: "North", className: "top-0 inset-x-0 h-1.5 cursor-ns-resize" }, + { dir: "South", className: "bottom-0 inset-x-0 h-1.5 cursor-ns-resize" }, + { dir: "West", className: "left-0 inset-y-0 w-1.5 cursor-ew-resize" }, + { dir: "East", className: "right-0 inset-y-0 w-1.5 cursor-ew-resize" }, + // Corners (sit above the edges so diagonal resize wins in the corner) + { dir: "NorthWest", className: "top-0 left-0 size-3 cursor-nwse-resize" }, + { dir: "NorthEast", className: "top-0 right-0 size-3 cursor-nesw-resize" }, + { dir: "SouthWest", className: "bottom-0 left-0 size-3 cursor-nesw-resize" }, + { dir: "SouthEast", className: "bottom-0 right-0 size-3 cursor-nwse-resize" }, +]; + +// Windows' frameless windows already resize via the native invisible +// border; only the WebKit-backed platforms need JS-driven grips. +const isWindows = + typeof navigator !== "undefined" && navigator.userAgent.includes("Windows"); + +export function WindowResizeHandles() { + if (isWindows) return null; + + return ( + <> + {HANDLES.map(({ dir, className }) => ( +
{ + // Primary button only; let the drag-region handle moves. + if (e.button !== 0) return; + e.preventDefault(); + void getCurrentWindow() + .startResizeDragging(dir) + .catch((err) => { + console.error("[window] startResizeDragging failed:", err); + }); + }} + /> + ))} + + ); +}