Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 24 additions & 2 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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:
Expand All @@ -42,3 +63,4 @@ jobs:
releaseBody: "See the assets below to download and install this version."
releaseDraft: true
prerelease: false
args: ${{ matrix.args }}
13 changes: 10 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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,
Expand All @@ -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
Expand Down
1 change: 1 addition & 0 deletions src-tauri/capabilities/default.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
26 changes: 26 additions & 0 deletions src-tauri/tauri.linux.conf.json
Original file line number Diff line number Diff line change
@@ -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"
]
}
}
}
}
2 changes: 2 additions & 0 deletions src/components/layout/app-shell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -234,6 +235,7 @@ export function AppShell({ children }: { children: ReactNode }) {
{mode === "floating" && hasTrack && <FloatingPlayerSync />}
</div>
<DragSnapOverlay />
<WindowResizeHandles />
<SettingsDialog />
<PremiumGateDialog />
<ChannelPickerDialog />
Expand Down
2 changes: 2 additions & 0 deletions src/components/layout/floating-player-app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -64,6 +65,7 @@ export default function FloatingPlayerApp() {
<main className="relative flex-1">
<PlayerBar variant="floating" />
</main>
<WindowResizeHandles />
</div>
</TooltipProvider>
<Toaster />
Expand Down
74 changes: 74 additions & 0 deletions src/components/layout/window-resize-handles.tsx
Original file line number Diff line number Diff line change
@@ -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 }) => (
<div
key={dir}
// Corners are 12px, edges 6px — corners come last in the DOM so
// they paint on top and win the overlap.
className={`absolute z-50 ${className}`}
onMouseDown={(e) => {
// 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);
});
}}
/>
))}
</>
);
}