Compare commits

...
5 Commits
Author SHA1 Message Date
yonluandClaude Opus 4.8 0001135f3a Merge wip into main: Homebrew tap + smartplaylist materialization
Sync Homebrew formula / sync-formula (push) Successful in 5s
Build & publish Arch package / arch-package (push) Successful in 2m11s
Adds the Homebrew tap formula and Gitea release-sync workflow, plus
smartplaylist materialize-on-creation, volume-control scroll/drag, and
smartplaylist cover-art batch loading.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 15:04:27 -04:00
yonluandClaude Opus 4.8 08da4f2774 feat(smartplaylist): materialize on creation and show track counts
Smart playlists now evaluate and snapshot their rules at creation time
instead of only lazily on first open, so the playlist list can show a
real track count in place of the "Smart" label. A one-time idempotent
startup sweep backfills snapshots for smart playlists created before
creation-time materialization existed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 15:01:49 -04:00
yonluandClaude Opus 4.8 d3fc2b9237 build(packaging): add Homebrew tap formula and release sync
Build-from-source formula for macOS/Linuxbrew, mirroring the Arch
PKGBUILD. A Gitea workflow recomputes the tarball checksum on each
version tag and syncs the formula into the homebrew-yellowjacket tap
repo, so releases need no manual formula edits.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 15:01:33 -04:00
yonluandClaude Opus 4.8 a181a98ce3 feat(volume-control): scroll-to-adjust icon + live debounced slider drag
Wheel over the volume icon steps volume by 5. The slider now updates
live on drag (@input) instead of only on release, debounced 60ms to
avoid spamming SetVolume. A local pendingVolume tracks intent so rapid
events accumulate and UI stays responsive ahead of the backend echo.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 14:50:34 -04:00
yonluandClaude Opus 4.8 16886c92cf perf(smartplaylist): batch-load cover art + MBIDs instead of per-row subquery
The autotag overhaul added cover-art and MusicBrainz-ID columns to
leanTrackQuery to support the new track-row styling, reintroducing the
per-row correlated subquery anti-pattern (artist_mbid) plus a cover_art
join inside the whole-library derived table. Both ran for every track
before WHERE/LIMIT, so smart-playlist evaluation cost scaled with
library size rather than result size — several seconds for a 500-track
playlist that was previously sub-second.

Move these presentation-only fields into a batched fetchArtwork pass
keyed by the matched recording_ids, mirroring the existing fetchGenres
batch. Cost is now proportional to results. Add TestEvaluate_ArtworkEnrichment
(no prior coverage of these fields) and an artwork_ms debug metric.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 14:31:33 -04:00
9 changed files with 608 additions and 61 deletions
+86
View File
@@ -0,0 +1,86 @@
name: Sync Homebrew formula
# On every version tag, recompute the release tarball checksum and push an
# updated Formula/yellowjacket.rb into the Homebrew tap repo. Keeping the tap
# in a separate repo (github.com/Shadow-Puppet/homebrew-yellowjacket) is what
# lets users install with a single command:
#
# brew install shadow-puppet/yellowjacket/yellowjacket
#
# (`shadow-puppet/yellowjacket` is shorthand for the homebrew-yellowjacket repo;
# brew auto-taps it, so no separate `brew tap` step is needed.)
on:
push:
tags:
- "v*"
jobs:
sync-formula:
runs-on: ubuntu-latest
env:
# GitHub PAT (or fine-grained token) with write access to the tap repo.
TAP_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }}
# Gitea source that serves the release tarball referenced by the formula.
SOURCE_TARBALL_BASE: https://git.ljones.me/yonlu/yellowjacket/archive
# separate GitHub tap repo the formula is published to.
TAP_REPO: Shadow-Puppet/homebrew-yellowjacket
steps:
- name: Check out source (for the canonical formula)
uses: actions/checkout@v4
- name: Compute version and tarball checksum
run: |
set -euo pipefail
TAG="${GITHUB_REF_NAME}" # e.g. v1.3.0
VERSION="${TAG#v}" # e.g. 1.3.0
TARBALL="${SOURCE_TARBALL_BASE}/${TAG}.tar.gz"
echo "Fetching ${TARBALL}"
# Retry briefly: the tag archive can lag a few seconds behind the push.
for attempt in 1 2 3 4 5; do
if curl -fSsL "$TARBALL" -o release.tar.gz; then
break
fi
echo "attempt ${attempt} failed, retrying..."
sleep 5
done
SHA256="$(sha256sum release.tar.gz | cut -d' ' -f1)"
echo "version=${VERSION} sha256=${SHA256}"
echo "VERSION=${VERSION}" >> "$GITHUB_ENV"
echo "SHA256=${SHA256}" >> "$GITHUB_ENV"
- name: Render the formula with the new version and checksum
run: |
set -euo pipefail
src="packaging/homebrew/Formula/yellowjacket.rb"
# Rewrite only the two managed lines; the interpolated url picks up the
# new version automatically.
sed -E \
-e "s|^ version \".*\"| version \"${VERSION}\"|" \
-e "s|^ sha256 \".*\"| sha256 \"${SHA256}\"|" \
"$src" > yellowjacket.rb
echo "----- rendered formula -----"
cat yellowjacket.rb
- name: Push to the Homebrew tap repo
run: |
set -euo pipefail
git clone "https://x-access-token:${TAP_TOKEN}@github.com/${TAP_REPO}.git" tap
mkdir -p tap/Formula
cp yellowjacket.rb tap/Formula/yellowjacket.rb
cd tap
git config user.name "yellowjacket-ci"
git config user.email "yj@yellowjacket.app"
if git diff --quiet; then
echo "Formula already up to date; nothing to push."
exit 0
fi
git add Formula/yellowjacket.rb
git commit -m "yellowjacket ${VERSION}"
git push origin HEAD:main
+3
View File
@@ -202,6 +202,9 @@ func (yj *YellowJacketApp) OnStartup(ctx context.Context) {
yj.playlist.EnsureDefaultPlaylist()
// Recover playlists that lost tracks from a pre-fix FullRescan.
go yj.playlist.RepopulateFromM3U()
// Backfill snapshots for smart playlists created before
// creation-time materialization existed.
go yj.playlist.MaterializeUnmaterializedSmartPlaylists()
// Initialize speaker hardware (player struct created in
// NewYellowJacketApp for Wails binding registration).
+74 -2
View File
@@ -2591,9 +2591,9 @@ func (s *Service) CreateSmartPlaylist(
)
}
defer func() { _ = rows.Close() }()
if !rows.Next() {
_ = rows.Close()
return Summary{}, fmt.Errorf(
"failed to create smart playlist: %w",
errNoRowReturned,
@@ -2610,6 +2610,8 @@ func (s *Service) CreateSmartPlaylist(
if err := rows.Scan(
&id, &retName, &createdAt, &updatedAt,
); err != nil {
_ = rows.Close()
s.logger.Error(
"Failed to create smart playlist",
"name", trimmed, "err", err,
@@ -2620,11 +2622,26 @@ func (s *Service) CreateSmartPlaylist(
)
}
// Close before RefreshSmartPlaylist issues its own queries
// (MaxOpenConns=1 test DBs would deadlock).
_ = rows.Close()
s.logger.Info(
"Smart playlist created",
"id", id, "name", retName,
)
// Materialize the rule set once at creation so the playlist has a
// track snapshot immediately (track counts, instant open). A failed
// evaluation is non-fatal — the lazy path re-materializes on first
// open.
if err := s.RefreshSmartPlaylist(id); err != nil {
s.logger.Warn(
"Failed to materialize smart playlist at creation",
"id", id, "err", err,
)
}
summary := Summary{
ID: id,
Name: retName,
@@ -2821,6 +2838,61 @@ func (s *Service) GetSmartPlaylistTracks(
return s.GetPlaylistTracks(playlistID)
}
// MaterializeUnmaterializedSmartPlaylists evaluates and snapshots any
// smart playlist that has never been materialized (smart_snapshot_at
// IS NULL) — e.g. playlists created before creation-time
// materialization existed. It runs once at startup and is idempotent:
// once every smart playlist has a snapshot it becomes a no-op. Errors
// on individual playlists are logged and skipped so one bad rule set
// doesn't block the rest.
func (s *Service) MaterializeUnmaterializedSmartPlaylists() {
// SAFETY: Static SELECT for smart_snapshot_at column not yet in
// sqlc schema. No parameters.
rows, err := s.db.QueryContext(
`SELECT id FROM playlists
WHERE is_smart = 1 AND smart_snapshot_at IS NULL`,
)
if err != nil {
s.logger.Error(
"Failed to list unmaterialized smart playlists",
"err", err,
)
return
}
var ids []int64
for rows.Next() {
var id int64
if err := rows.Scan(&id); err != nil {
s.logger.Error(
"Failed to scan smart playlist id", "err", err,
)
continue
}
ids = append(ids, id)
}
// Close before RefreshSmartPlaylist issues its own queries
// (MaxOpenConns=1 test DBs would deadlock).
_ = rows.Close()
for _, id := range ids {
if err := s.RefreshSmartPlaylist(id); err != nil {
s.logger.Warn(
"Failed to materialize smart playlist snapshot",
"id", id, "err", err,
)
continue
}
}
}
// EvaluateSmartPlaylist loads the rule set for a smart playlist
// from the database and evaluates it against the track library,
// returning the matching tracks.
+174 -50
View File
@@ -555,11 +555,7 @@ const leanTrackQuery = `SELECT
af.bitrate,
af.file_size,
af.play_count,
COALESCE(af.last_played, '') AS last_played,
af.cover_art_path,
af.artist_mbid,
af.release_group_mbid,
af.recording_mbid
COALESCE(af.last_played, '') AS last_played
FROM (
SELECT
af.id,
@@ -591,15 +587,7 @@ FROM (
af.file_size,
af.library_id,
af.play_count,
af.last_played,
COALESCE(ca.file_path, '') AS cover_art_path,
COALESCE((SELECT a.mbid
FROM artist_credit_artist aca
JOIN artists a ON a.id = aca.artist_id
WHERE aca.credit_id = ac.id
LIMIT 1), '') AS artist_mbid,
COALESCE(rg.mbid, '') AS release_group_mbid,
COALESCE(r.mbid, '') AS recording_mbid
af.last_played
FROM audio_files af
LEFT JOIN recordings r ON af.recording_id = r.id
LEFT JOIN artist_credit ac ON r.artist_credit_id = ac.id
@@ -610,7 +598,6 @@ FROM (
GROUP BY recording_id
) rgr ON r.id = rgr.recording_id
LEFT JOIN release_groups rg ON rgr.release_group_id = rg.id
LEFT JOIN cover_art ca ON rg.cover_art_id = ca.id
LEFT JOIN file_types ft ON af.file_type_id = ft.id
) af`
@@ -719,6 +706,38 @@ func Evaluate(
genreDuration := time.Since(genreStart)
// Batch-load cover art + MusicBrainz IDs for the matched rows only.
// These fields are presentation-only (track-row styling); keeping
// them out of the lean query avoids a per-row correlated subquery
// and cover-art join over the whole library before WHERE/LIMIT.
artStart := time.Now()
artworkByRecording, err := fetchArtwork(db, recordingIDs)
if err != nil {
return nil, err
}
for i, rid := range recordingIDs {
art, ok := artworkByRecording[rid]
if !ok {
continue
}
tracks[i].ArtistMBID = art.artistMBID
tracks[i].ReleaseGroupMBID = art.releaseGroupMBID
tracks[i].RecordingMBID = art.recordingMBID
if art.coverArtPath != "" {
urls := coverart.ResolveURLs(art.coverArtPath)
tracks[i].CoverArtPath = urls.Original
tracks[i].CoverArtSmall = urls.Small
tracks[i].CoverArtMedium = urls.Medium
tracks[i].CoverArtLarge = urls.Large
}
}
artDuration := time.Since(artStart)
// Apply genre-sort and deferred LIMIT in Go if needed.
if sortByGenre {
dir := 1
@@ -743,6 +762,7 @@ func Evaluate(
"tracks", len(tracks),
"main_ms", mainDuration.Milliseconds(),
"genres_ms", genreDuration.Milliseconds(),
"artwork_ms", artDuration.Milliseconds(),
"total_ms", time.Since(start).Milliseconds(),
)
@@ -778,11 +798,6 @@ func scanTracks(rows *sql.Rows) ([]library.Track, []int64, error) {
fileSize int64
playCount int64
lastPlayed string
coverArtPath string
artistMBID string
releaseGroupMBID string
recordingMBID string
)
if err := rows.Scan(
@@ -792,8 +807,6 @@ func scanTracks(rows *sql.Rows) ([]library.Track, []int64, error) {
&sampleRate, &bitDepth, &channels,
&bitrate, &fileSize,
&playCount, &lastPlayed,
&coverArtPath, &artistMBID,
&releaseGroupMBID, &recordingMBID,
); err != nil {
return nil, nil, fmt.Errorf(
"could not scan smart playlist row: %w", err,
@@ -801,34 +814,23 @@ func scanTracks(rows *sql.Rows) ([]library.Track, []int64, error) {
}
track := library.Track{
TrackName: title,
ArtistName: artistName,
TrackLength: strconv.FormatInt(lengthMs, 10),
FilePath: filePath,
TrackNumber: trackNumber.Int64,
DiscNumber: discNumber.Int64,
Album: album,
Year: year,
Composer: composer,
FileType: fileType,
SampleRate: sampleRate,
BitDepth: bitDepth,
Channels: channels,
Bitrate: bitrate,
FileSize: fileSize,
PlayCount: playCount,
LastPlayed: lastPlayed,
ArtistMBID: artistMBID,
ReleaseGroupMBID: releaseGroupMBID,
RecordingMBID: recordingMBID,
}
if coverArtPath != "" {
urls := coverart.ResolveURLs(coverArtPath)
track.CoverArtPath = urls.Original
track.CoverArtSmall = urls.Small
track.CoverArtMedium = urls.Medium
track.CoverArtLarge = urls.Large
TrackName: title,
ArtistName: artistName,
TrackLength: strconv.FormatInt(lengthMs, 10),
FilePath: filePath,
TrackNumber: trackNumber.Int64,
DiscNumber: discNumber.Int64,
Album: album,
Year: year,
Composer: composer,
FileType: fileType,
SampleRate: sampleRate,
BitDepth: bitDepth,
Channels: channels,
Bitrate: bitrate,
FileSize: fileSize,
PlayCount: playCount,
LastPlayed: lastPlayed,
}
tracks = append(tracks, track)
@@ -929,6 +931,128 @@ func fetchGenres(
return result, nil
}
// trackArtwork holds the presentation-only cover-art path and
// MusicBrainz identifiers attached to a matched track after the main
// filter query, keyed by recording_id.
type trackArtwork struct {
coverArtPath string
artistMBID string
releaseGroupMBID string
recordingMBID string
}
// fetchArtwork batch-loads cover-art paths and MusicBrainz IDs for the
// given recording_ids in a single IN-list query. These fields drive
// track-row styling only, so scoping them to the matched result set
// keeps the cost proportional to results rather than library size.
func fetchArtwork(
db *database.DB, ids []int64,
) (map[int64]trackArtwork, error) {
if len(ids) == 0 {
return nil, nil
}
// Deduplicate to keep the IN list minimal.
seen := make(map[int64]struct{}, len(ids))
unique := make([]int64, 0, len(ids))
for _, id := range ids {
if id == 0 {
continue
}
if _, ok := seen[id]; ok {
continue
}
seen[id] = struct{}{}
unique = append(unique, id)
}
if len(unique) == 0 {
return nil, nil
}
placeholders := make([]string, len(unique))
for i := range unique {
placeholders[i] = "?"
}
inList := strings.Join(placeholders, ", ")
// A recording's artist credit can name several artists; the old
// correlated subquery picked one via LIMIT 1. GROUP BY r.id with
// MIN() reproduces a single stable value without multiplying rows.
// SAFETY: placeholders are static "?" tokens; every value is
// parameterized. The IN list is bound twice (subquery + outer).
query := `SELECT r.id,
COALESCE(MIN(ca.file_path), '') AS cover_art_path,
COALESCE(MIN(a.mbid), '') AS artist_mbid,
COALESCE(MIN(rg.mbid), '') AS release_group_mbid,
COALESCE(r.mbid, '') AS recording_mbid
FROM recordings r
LEFT JOIN artist_credit ac ON r.artist_credit_id = ac.id
LEFT JOIN artist_credit_artist aca ON aca.credit_id = ac.id
LEFT JOIN artists a ON a.id = aca.artist_id
LEFT JOIN (
SELECT recording_id,
MIN(release_group_id) AS release_group_id
FROM release_group_recordings
WHERE recording_id IN (` + inList + `)
GROUP BY recording_id
) rgr ON r.id = rgr.recording_id
LEFT JOIN release_groups rg ON rgr.release_group_id = rg.id
LEFT JOIN cover_art ca ON rg.cover_art_id = ca.id
WHERE r.id IN (` + inList + `)
GROUP BY r.id`
args := make([]any, 0, len(unique)*2)
for range 2 {
for _, id := range unique {
args = append(args, id)
}
}
rows, err := db.QueryContext(query, args...)
if err != nil {
return nil, fmt.Errorf(
"smart playlist artwork fetch failed: %w", err,
)
}
defer func() { _ = rows.Close() }()
result := make(map[int64]trackArtwork, len(unique))
for rows.Next() {
var (
rid int64
art trackArtwork
)
if err := rows.Scan(
&rid, &art.coverArtPath, &art.artistMBID,
&art.releaseGroupMBID, &art.recordingMBID,
); err != nil {
return nil, fmt.Errorf(
"could not scan smart playlist artwork row: %w", err,
)
}
result[rid] = art
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf(
"smart playlist artwork iteration error: %w", err,
)
}
return result, nil
}
// ParseRuleSet parses a JSON string into a validated RuleSet.
func ParseRuleSet(jsonStr string) (RuleSet, error) {
var rs RuleSet
@@ -4,6 +4,7 @@ import (
"strings"
"testing"
"yellowjacket/backend/coverart"
"yellowjacket/backend/database"
)
@@ -812,6 +813,85 @@ func TestEvaluate_TextIs(t *testing.T) {
}
}
// TestEvaluate_ArtworkEnrichment verifies the presentation-only
// cover-art and MusicBrainz-ID fields are attached to matched tracks
// by the batched fetchArtwork pass (they are no longer part of the
// lean filter query).
func TestEvaluate_ArtworkEnrichment(t *testing.T) {
t.Parallel()
db := database.NewTestDB(t)
// Minimal FK chain: cover_art → release_group(mbid) →
// release_group_recordings → recording(mbid) → audio_file, plus
// artist_credit → artist_credit_artist → artist(mbid).
exec := func(query string, args ...any) {
t.Helper()
if _, err := db.ExecContext(query, args...); err != nil {
t.Fatalf("seed %q: %v", query, err)
}
}
// file_types are pre-seeded by the schema (id 0 = .mp3).
exec("INSERT INTO cover_art (id, file_path, mime_type) " +
"VALUES (1, '/covers/abc123.jpg', 'image/jpeg')")
exec("INSERT INTO artists (id, name, mbid) " +
"VALUES (1, 'Queen', 'artist-mbid-1')")
exec("INSERT INTO artist_credit (id, text) VALUES (1, 'Queen')")
exec("INSERT INTO artist_credit_artist (credit_id, artist_id) " +
"VALUES (1, 1)")
exec("INSERT INTO release_groups (id, name, cover_art_id, mbid) " +
"VALUES (1, 'A Night at the Opera', 1, 'rg-mbid-1')")
exec("INSERT INTO recordings (id, name, artist_credit_id, mbid) " +
"VALUES (1, 'Bohemian Rhapsody', 1, 'rec-mbid-1')")
exec("INSERT INTO release_group_recordings " +
"(release_group_id, recording_id) VALUES (1, 1)")
exec("INSERT INTO audio_files (id, file_path, " +
"length_milliseconds, recording_id, file_type_id) " +
"VALUES (1, '/music/bohemian.mp3', 354000, 1, 0)")
tracks, err := Evaluate(db, RuleSet{
Rules: []Rule{
{Field: "artist", Operator: "is", Value: "Queen"},
},
})
if err != nil {
t.Fatalf("Evaluate: %v", err)
}
if len(tracks) != 1 {
t.Fatalf("got %d tracks, want 1", len(tracks))
}
tr := tracks[0]
if tr.ArtistMBID != "artist-mbid-1" {
t.Errorf("ArtistMBID = %q, want artist-mbid-1", tr.ArtistMBID)
}
if tr.ReleaseGroupMBID != "rg-mbid-1" {
t.Errorf("ReleaseGroupMBID = %q, want rg-mbid-1",
tr.ReleaseGroupMBID)
}
if tr.RecordingMBID != "rec-mbid-1" {
t.Errorf("RecordingMBID = %q, want rec-mbid-1",
tr.RecordingMBID)
}
wantURLs := coverart.ResolveURLs("/covers/abc123.jpg")
if tr.CoverArtPath != wantURLs.Original {
t.Errorf("CoverArtPath = %q, want %q",
tr.CoverArtPath, wantURLs.Original)
}
if tr.CoverArtSmall != wantURLs.Small {
t.Errorf("CoverArtSmall = %q, want %q",
tr.CoverArtSmall, wantURLs.Small)
}
}
func TestEvaluate_TextContains(t *testing.T) {
t.Parallel()
@@ -6,14 +6,28 @@ import type WaSlider from '@awesome.me/webawesome/dist/components/slider/slider.
import { PlayerController } from '@store/controllers/player-controller';
import { designTokens } from '../../../styles/tokens.css';
/** Volume change (0-100) applied per scroll-wheel tick. */
const WHEEL_STEP = 5;
/** Delay before a live volume change is pushed to the backend. */
const VOLUME_DEBOUNCE_MS = 60;
@customElement('volume-control')
export class VolumeControl extends LitElement {
private player = new PlayerController(this);
private boundHandleOutsideClick = this.handleOutsideClick.bind(this);
private volumeDebounceTimer?: ReturnType<typeof setTimeout>;
@state()
private showSlider = false;
// Locally-tracked volume while the user is actively dragging or scrolling.
// The store's volume only updates once the backend echoes VolumeChanged
// (which we debounce), so we track intent here for responsive UI and to let
// rapid events accumulate. Cleared once the store catches up.
@state()
private pendingVolume: number | null = null;
static override styles = [designTokens, css`
:host {
position: relative;
@@ -70,8 +84,12 @@ export class VolumeControl extends LitElement {
// DERIVED STATE
// ===================================================================
private get currentVolume(): number {
return this.pendingVolume ?? this.player.volume;
}
private get volumeIcon(): string {
const vol = this.player.volume;
const vol = this.currentVolume;
if (vol === 0) return 'volume-xmark';
if (vol <= 50) return 'volume-low';
@@ -86,6 +104,15 @@ export class VolumeControl extends LitElement {
override disconnectedCallback() {
super.disconnectedCallback();
document.removeEventListener('click', this.boundHandleOutsideClick);
clearTimeout(this.volumeDebounceTimer);
}
override willUpdate() {
// Once the backend has echoed our pending change back through the store,
// drop the local override so external volume changes are reflected again.
if (this.pendingVolume !== null && this.player.volume === this.pendingVolume) {
this.pendingVolume = null;
}
}
// ===================================================================
@@ -113,21 +140,37 @@ export class VolumeControl extends LitElement {
}
private handleInput(e: Event) {
const value = (e.target as WaSlider).value;
this.player.setVolume(value);
this.changeVolume((e.target as WaSlider).value);
}
private handleWheel(e: WheelEvent) {
e.preventDefault();
const direction = e.deltaY < 0 ? 1 : -1;
this.changeVolume(this.currentVolume + direction * WHEEL_STEP);
}
private handlePopupClick(e: Event) {
e.stopPropagation();
}
/** Update the UI immediately and push to the backend on a short debounce. */
private changeVolume(value: number) {
const clamped = Math.max(0, Math.min(100, Math.round(value)));
this.pendingVolume = clamped;
clearTimeout(this.volumeDebounceTimer);
this.volumeDebounceTimer = setTimeout(() => {
this.player.setVolume(clamped);
}, VOLUME_DEBOUNCE_MS);
}
// ===================================================================
// RENDER
// ===================================================================
override render() {
return html`
<button @click="${this.toggleSlider}">
<button @click="${this.toggleSlider}" @wheel="${this.handleWheel}">
<wa-icon name=${this.volumeIcon}></wa-icon>
</button>
${this.showSlider
@@ -137,8 +180,8 @@ export class VolumeControl extends LitElement {
orientation="vertical"
min="0"
max="100"
.value="${this.player.volume}"
@change="${this.handleInput}"
.value="${this.currentVolume}"
@input="${this.handleInput}"
></wa-slider>
</div>
`
@@ -1930,9 +1930,7 @@ export class PlaylistView extends LitElement {
index: number,
) {
const trackCount = entry.tracks.length;
const countLabel = entry.summary.IsSmart
? 'Smart'
: `${trackCount} track${trackCount !== 1 ? 's' : ''}`;
const countLabel = `${trackCount} track${trackCount !== 1 ? 's' : ''}`;
const isDragOver =
this.dragOverPlaylistIndex === index;
@@ -0,0 +1,65 @@
# typed: false
# frozen_string_literal: true
# YellowJacket — cross-platform desktop music player built with Wails (Go + Lit).
#
# This formula builds from source. The Wails toolchain (`go tool wails`) resolves
# from the tool directives in go.mod, and Wails drives the frontend install/build
# itself (pnpm), so only the Go toolchain, Node, and pnpm are needed at build time.
#
# This file is the canonical source. On each tagged release, CI computes the
# tarball checksum and syncs an updated copy into the homebrew-yellowjacket tap
# repo (see .gitea/workflows/homebrew-formula.yml). The `version`/`sha256` lines
# below are what CI rewrites — keep them on their own lines.
class Yellowjacket < Formula
desc "Cross-platform desktop music player — local library, MusicBrainz explore & auto-tag"
homepage "https://git.ljones.me/yonlu/yellowjacket"
version "1.3.0"
url "https://git.ljones.me/yonlu/yellowjacket/archive/v#{version}.tar.gz"
sha256 "11929d9a7a32839f86213502b698a02376b38f0838fa20610408f062423899e5"
license :cannot_represent # custom license — see repository
head "https://git.ljones.me/yonlu/yellowjacket.git", branch: "main"
depends_on "go" => :build
depends_on "node" => :build
depends_on "pnpm" => :build
# Wails targets macOS and Linux. On Linux, Homebrew builds against the system
# WebKitGTK/GTK stack, which must be present (webkit2gtk-4.1, gtk3, alsa-lib).
on_linux do
depends_on "pkg-config" => :build
end
def install
ENV["CGO_ENABLED"] = "1"
# Keep Go resolving modules from the network into its sandboxed cache.
ENV["GOFLAGS"] = "-mod=mod"
commit = build.head? ? "HEAD" : "v#{version}"
ldflags = "-s -w -X 'main.version=v#{version}' -X 'main.commit=#{commit}'"
system "go", "generate", "./..."
system "go", "tool", "wails", "build",
"-tags", "webkit2_41",
"-clean", "-trimpath",
"-ldflags", ldflags
# Wails emits a .app bundle on macOS and a bare ELF binary on Linux.
if OS.mac?
prefix.install "build/bin/YellowJacket.app"
bin.write_exec_script "#{prefix}/YellowJacket.app/Contents/MacOS/YellowJacket"
else
bin.install "build/bin/yellowjacket"
end
end
test do
# The GUI binary has no headless mode; assert it was built and is runnable.
if OS.mac?
assert_predicate prefix/"YellowJacket.app/Contents/MacOS/YellowJacket", :executable?
else
assert_predicate bin/"yellowjacket", :executable?
end
end
end
+76
View File
@@ -0,0 +1,76 @@
# YellowJacket Homebrew formula
The formula builds YellowJacket from source on macOS and Linuxbrew, mirroring
the Arch `PKGBUILD`: the Wails toolchain resolves from `go.mod`'s tool
directives and drives the frontend build itself, so the only build inputs are
Go, Node, and pnpm.
```
packaging/homebrew/
└── Formula/
└── yellowjacket.rb ← canonical source; CI syncs it to the tap repo
```
## Installing
```bash
brew install shadow-puppet/yellowjacket/yellowjacket
```
`shadow-puppet/yellowjacket` is Homebrew shorthand for the tap repo
`github.com/Shadow-Puppet/homebrew-yellowjacket`. Brew auto-taps it, so there's
no separate `brew tap` step. To build the tip of `main` instead of the latest
release, add `--HEAD`.
## How publishing works
This directory holds the **canonical** formula. The tap users install from lives
in a **separate** repo — `homebrew-yellowjacket` — because Homebrew only
discovers formulae from a repo whose name starts with `homebrew-`, with the
formula at a top-level `Formula/`. Keeping it separate is also why nothing has
to live in this repo's root.
On every version tag (`v*`), `.gitea/workflows/homebrew-formula.yml`:
1. downloads the GitHub release tarball for that tag,
2. computes its `sha256`,
3. rewrites the `version` and `sha256` lines in the formula, and
4. commits the result to `homebrew-yellowjacket`'s `Formula/yellowjacket.rb`.
So a normal release needs **no manual formula edits** — tag, and the tap updates
itself. (This is the Homebrew equivalent of the Arch package's publish workflow.)
### About the `sha256`
Homebrew re-downloads the source tarball on each install and refuses to build
unless its checksum matches `sha256` — integrity/tamper detection. The committed
value here is a `REPLACE_WITH_...` placeholder on purpose; the real checksum is
computed and injected by CI at release time, so it never has to be maintained by
hand. (The Arch `PKGBUILD` sidesteps this with `SKIP` because it clones over git
rather than downloading a tarball.)
## One-time setup
1. **Create the tap repo:** `Shadow-Puppet/homebrew-yellowjacket` on GitHub,
with a `main` branch. It can start empty — the first tagged release seeds
`Formula/yellowjacket.rb`.
2. **Add a CI secret:** `HOMEBREW_TAP_TOKEN` — a GitHub token with write access
to that repo (a fine-grained PAT scoped to `homebrew-yellowjacket`, Contents:
read/write, is enough).
That's it. The source repo must be public (or the tap private with an
authenticated `brew install`) for brew to fetch the release tarball.
## Notes
- **License**: declared as `license :cannot_represent` (custom license). Replace
with the correct SPDX identifier once the license is finalized.
- **macOS vs Linux**: `wails build` produces a `YellowJacket.app` bundle on
macOS (installed under the Cellar with an `exec` shim in `bin`) and a bare
`yellowjacket` binary on Linux (installed to `bin`).
- **Linuxbrew**: building on Linux additionally needs the system WebKitGTK/GTK
stack (`webkit2gtk-4.1`, `gtk3`, `alsa-lib`) — OS packages, not Homebrew deps.
macOS needs only the Xcode Command Line Tools.
- **Cask alternative**: if you later ship prebuilt macOS `.dmg`/`.zip` artifacts,
a Homebrew *cask* pointing at those installs faster than this source build.
This formula is the source-build path.