Skip to content

macos support + a pile of playback and player fixes#33

Draft
yuvrajangadsingh wants to merge 50 commits into
NUber-dev:mainfrom
yuvrajangadsingh:mac-integration-v2
Draft

macos support + a pile of playback and player fixes#33
yuvrajangadsingh wants to merge 50 commits into
NUber-dev:mainfrom
yuvrajangadsingh:mac-integration-v2

Conversation

@yuvrajangadsingh

@yuvrajangadsingh yuvrajangadsingh commented Jul 14, 2026

Copy link
Copy Markdown

hey, i ported ytubic to macos and ended up fixing a bunch of playback and player things along the way. everything below is runtime verified on a real mac (apple silicon, macos 15) over several days of daily listening. i dont have a windows machine, so windows is NOT tested, happy to adjust anything that looks off for win.

mac port

  • native window chrome: overlay titlebar, traffic lights, fullscreen aware insets (tauri.macos.conf.json)
  • media keys / now playing via a small objc bridge (now_playing.rs). souvlaki stays windows-only behind cfg(windows), both registering would fight over the system now playing entry
  • the macos .app never rebuilt because bundle.targets was nsis-only; the platform config fixes that
  • login window sends a safari ua on mac (matches the real wkwebview fingerprint), windows keeps YT_LOGIN_UA

playback correctness

  • wkwebview (avfoundation) reads yt-dlps un-remuxed dash m4a headers at exactly DOUBLE the real length. no ffmpeg in the bundle means no fixup; chromium reads the sample tables and never sees this, which is why it never showed on windows. it was behind every "song keeps playing after it ended" report on mac. fixes: clamp against the tracks listed duration, fetch the length from the tracks own /next row when a queue entry arrives without one, and a guard that advances when a doubled file runs past its real end
  • long dead outros auto-advance using synced lyrics as the reference (extended uploads with minutes of trailing filler)
  • yt-dlp gets a client chain (android_vr,ios) so videos drm-blocked on one client resolve on the other instead of 403ing and skipping
  • the clean-audio hunt has duration + artist gates so it cant swap a track to a sped-up bootleg

lyrics

  • a synced lrclib record beats a plain one no matter which endpoint returned it, lrclib keeps duplicate rows per song and the exact-match endpoint kept hitting the plain-only duplicate
  • version qualifiers (remix/live) outrank duration closeness, so a remix upload doesnt get the originals timings
  • timestamps rescale for sped-up/slowed re-uploads (a tempo change cant be fixed by a constant offset)
  • artist-less rows dont query lyric providers at all, title-only matching returned confidently wrong lyrics

player

  • apple music style fullscreen: player column on the left (art, meta, seek with remaining time, transport, persistent volume slider), lyrics fill the rest with big bold lines and a visible window that melts into the pane edge
  • artwork crossfades on track change and never swaps mid-track (the itunes hi-res upgrade only applies if it resolves before the art settles)
  • accent extraction clamps near-greyscale covers to white and lifts dark accents in hsl, so the seek fill always reads on top of its own cover
  • the source toggle now actually plays the video: an explicit switch streams the progressive mv file (?video=1, cached side by side with the audio) and fullscreen shows it in place of the artwork. seeded records stay audio-only so nothing starts pulling full videos silently

merge notes

your 0.3.1 is merged in: retry-nonce stream retries and the media-control listener are intact, the cache sweeper unions both stream variants (.video.mp4) with the new .meta.json sidecars, and discord/lastfm ride along untouched.

happy to split any of this into smaller prs if thats easier to review.

the login window hardcodes a chrome-on-windows ua. that matches reality
under webview2, but on macos the engine is wkwebview, which fingerprints
as webkit. google's consumer account check flags the mismatch and blocks
sign-in with "this browser or app may not be secure". send a safari ua
on macos so the claimed browser matches the actual engine; other
platforms keep the existing ua.
one stalled provider (hung request that never resolves) kept its query
in isLoading forever, so the panel showed "Loading lyrics..." until you
changed tracks. give each provider an 8s budget via AbortSignal.timeout
(with an AbortController fallback for older webkit) threaded through
every fetch in its chain. musixmatch/genius swallow their own errors
into null, so on an aborted run they now rethrow instead — a timeout
gets retried by react-query rather than cached as "no lyrics" for an
hour. all three queries settle now, so the panel lands on the existing
"No lyrics found." state instead of spinning.
macos trackpad momentum scrolling outruns the virtualizer at 8 rows of
overscan. flicking through a long playlist shows white gaps before rows
mount. 16 keeps rows painted through the fastest fling at the cost of a
few extra mounted rows.
lrclib and musixmatch match by a text search, so a track with no real
synced lyrics could pull back a totally different song's words. wire the
existing hitMatches check into both providers and tighten match.ts so a
single shared artist name (or a loose token overlap) no longer counts as
a match. jaccard + meaningfulContains + a collab-aware heuristic decide
it now.
webkit (wkwebview) on macos only decodes a narrow codec set, so the old
bestaudio pick could hand it an opus/webm track it refused to play. add
a mac audio ladder that prefers aac-in-mp4 and falls back through
opus/webm to a progressive muxed mp4 so audio always plays.

also add a separate video stream variant (progressive h264 mp4, itag 18
floor) cached under `<id>.video.mp4` so the same track can hold both an
audio-only and a music-video download side by side. the stream handler
sniffs the container and serves video/mp4 vs audio/* by magic bytes.
non-macos keeps its original webm-first selection.
…nter

the active line flipped 0.72s ahead of the vocal, which read as the
highlight racing the singer. drop the lookahead to 0.2s so it lands on
the beat. also raise the resting position from 0.36 to 0.45 of the
viewport so the active line sits closer to center with a bit more
upcoming text still visible.
some tracks stream a different edit than the lyric timings were cut to,
so the highlight runs a fixed amount ahead of or behind the vocal. add
a +/- 0.25s nudge that floats over the lyric column, shows the current
offset like "+0.75s", and resets on click. the offset applies to both
the active-line math and the click-to-seek target, and persists per
videoId in localStorage.
the expand button on the player card opens a full-window now-playing
view: the cover blown up and blurred as an ambient backdrop, the sharp
art in front, and synced lyrics beside it. esc or the chevron closes it.

when a track has no lyrics the empty right pane and the "no lyrics found"
line looked broken, so drop them and center the art on its own instead.

the seek fill, play button, and active shuffle/repeat pull a vibrant
accent from the cover art. a client-side canvas read taints on the
cors-less art cdns, so a small rust command fetches the bytes and picks
a saturated dominant color inside a legible lightness band, falling back
to brand red for near-monochrome covers.
the progressive yt-dlp stream reports its duration late, so the store
duration sat at 0 for the first seconds of a track. that collapsed the
seek bar's max to 1, which pinned the played fill as a stray red dot at
the far left and left the total time showing 0:00. fall back to the
browse metadata duration until the element reports its own, so the bar
scales right and the total reads correctly from the start.
when the selected source is the music video and the webview can't decode
that stream (media_err_decode / media_err_src_not_supported), the player
showed a raw error banner and skipped the track. instead drop that
track's source back to audio once and let the resolver retry with the
song stream, which every track has. the selected-source check stops it
looping: once we're on audio a repeat failure falls through to the
normal error handling.
toggling to the music video ran a fuzzy search and returned the first
result that wasn't the current id, which for a video-native track (one
that already is a music video) was a completely different clip.

thread the song/video kind onto queue tracks. a video-native track now
stays on its own id in both modes since its stream carries the audio
too, so it never searches. for genuine song<->video pairs, read the
counterpart id straight from innertube's /next wrapper
(playlistPanelVideoWrapperRenderer.counterpart + musicVideoType) and
seed the source toggle with it, so switching lands on the real other
version (same song, grouped by yt) rather than a search. song-native
tracks with no exposed counterpart keep the search as a fallback.
a tauri wkwebview only bridges navigator.mediaSession to windows smtc,
so macos control center, the touch bar, media keys and airpods showed
"not playing" and couldn't drive playback.

add a native mediaplayer.framework bridge (objc2): push title, artist,
album, duration, elapsed and play state into mpnowplayinginfocenter, and
register mpremotecommandcenter handlers (play, pause, toggle, next, prev,
seek) that emit tauri events the playback store already listens for. the
frontend invokes set_now_playing on track change, play/pause and seek.
all cfg(macos), a no-op elsewhere.

artwork is left out for now: the only artwork api this crate build
exposes is the block-based initWithBoundsSize:requestHandler:, whose
return-pointer ownership can't be validated without running on device,
and a wrong guess risks a crash when control center renders the art.
text, times and the transport commands work without it.
# Conflicts:
#	src/components/layout/lyrics-view.tsx
# Conflicts:
#	src-tauri/Cargo.lock
#	src-tauri/src/lib.rs
- tauri.macos.conf.json: native decorations with an overlay title bar
  (hidden title, traffic lights at 14,12) instead of the Windows-style
  custom frame; per-platform so Windows keeps decorations:false.
- Same file fixes macOS bundling: base config targets ["nsis"] which is
  Windows-only, so tauri build compiled the binary but never regenerated
  YTubic.app — installs silently shipped stale builds. targets ["app"]
  plus createUpdaterArtifacts:false (updater signing needs the private
  key, which local dev builds don't have).
- top-bar: hide the custom min/max/close cells on macOS and start the
  nav cluster clear of the traffic lights.
- capabilities: allow set-fullscreen (immersive player) and
  internal-toggle-maximize (drag-region double-click zoom).
…res cover fix

- yt-dlp: player_client tv,android_vr -> android_vr. YouTube is running
  a DRM experiment on the tv client (yt-dlp #12563), so every uncached
  download 403'd; android_vr downloads clean.
- accent extraction: walk an ordered candidate list (local iTunes cover
  first, then every thumbnail largest->smallest) instead of a single
  fragile URL, and fall back to a neutral grey rather than brand red
  when art has no vibrant color or the fetch fails. Shared hook drives
  both the compact player and the fullscreen view.
- image fetcher: follow same-kind redirects with per-hop validation,
  send browser-like headers, and allow the app's own loopback cover
  server, keeping the SSRF allowlist for everything else.
- iTunes artwork: the 100000x100000-999 URL trick now returns HTTP 400
  (Apple dropped it); request 3000x3000bb and bump the cover cache key
  so stale dead URLs re-resolve.
- fullscreen player: ambient backdrop + accent share the candidate
  list with an onError fallback, native macOS fullscreen while open.
- audio engine: auto-hunt the clean audio ("song") version for
  music-video uploads that YouTube didn't pair with a counterpart.
…t is unknown

Video uploads often carry no artist metadata. hitMatches deliberately
relaxed to title-only in that case, so an exact-title hit for a
different song sailed through: a 6:59 Jokhay track showed the lyrics of
a ~4-minute English song that happens to be called Bittersweet.

- match: durationMatches helper (both durations known, within ±4s).
- lrclib/musixmatch: when the request has no artist, keep only hits
  whose duration vouches for the match (musixmatch now receives the
  track duration and reads track_length off search hits).
- genius: search results carry no durations, so artist-less requests
  are unverifiable there — skip instead of guessing.
- sources: bump lyric query keys to v2 so persisted wrong-song entries
  re-resolve under the new gating.
- audio-engine: skip the auto audio-hunt for artist-less tracks — a
  bare-title search could swap playback itself to the wrong song, which
  is worse than wrong lyrics. The manual Song/Video switch still works.

Verified against live LRCLIB: "Bittersweet" with no artist at 419s —
old path picks a wrong 231s song, new path returns no lyrics.
The backdrop <img> was keyed by URL, so every change — track switches
and the mid-track thumbnail -> iTunes-cover upgrade — unmounted it and
dropped the view to the black scrim for a frame, which read as a blink.
Use the same two-slot 700ms opacity cross-fade the app-wide
BackgroundCover already uses; failed loads still advance the caller's
candidate list.
Stricter follow-up to the duration-vouch gate: with no artist metadata
there is nothing to verify a match against, so don't query the
providers at all, the panel goes straight to "No lyrics found." and
the fullscreen view centers the art instead of reserving a lyrics pane.
Provider-level duration gates stay as a second layer for direct callers.
Keys bumped again so persisted artist-less entries drop out.
Fresh indie releases often exist on no lyric DB at all (correctly
showing "No lyrics found." now that wrong-song matches are gated).
Give that dead end one door: a link that opens the default browser
with a quoted title + artist lyrics search.
The side card was top-heavy for lyric-less tracks: art, controls, a
lone "No lyrics found." and a dead column under it. Fill that space
with Up Next instead, the same QueueBody the queue toggle renders,
minus its close button, under a one-line no-lyrics caption. Lyrics
take the slot back automatically on tracks that have them.
The queue speaks for itself; the caption was dead weight over it.
Two attacks on the same complaint (a song that ends minutes before its
file does):

- the clean-audio hunt now fires for ANY queued kind, not just video
  rows, extended/looped re-uploads surface as ordinary song rows too.
  It goes through findCleanAudioAlternate, which verifies the title,
  cross-checks artists, and only swaps to a meaningfully shorter album
  version (>60s shorter for song rows; near-equal allowed for true
  video rows). No duration data means no swap.

- long-outro auto-advance: when the synced lyrics' last sung line sits
  more than 2 minutes before the end of the file, advance to the next
  track 60 seconds after the vocals end. Normal instrumental outros
  (under 2 min of tail) keep playing; seeking into the tail disables
  the skip for that track. Covers the cases the hunt can't: YT's own
  canonical entry sometimes IS the extended cut (No Guidance 8:41),
  and per-track video-source selections persist from older sessions.
- a synced record beats a plain one no matter which lrclib endpoint
  returned it. lrclib keeps duplicate rows per song (artist credited
  alone vs with features) and the exact-match endpoint kept hitting
  the plain-only duplicate
- version qualifiers (remix/live) now outrank duration closeness, so
  a remix upload doesnt get the originals timings just because the
  two edits are a second apart
- timestamps rescale when the upload is a sped-up/slowed edit of the
  record the timings were cut for. a constant offset can never fix a
  tempo change
- one endpoint erroring no longer throws away the other ones hit
- wkwebview reads yt-dlps un-remuxed dash m4a headers at exactly 2x
  the real length (no ffmpeg shipped, so no fixup; chromium reads the
  sample tables and never sees this). clamp against the tracks listed
  duration, fetch the length from the tracks own /next row when a
  queue entry arrives without one, and re-clamp when it lands
- a guard advances when a doubled file runs past its real end, and
  long dead outros auto-advance using synced lyrics as the reference
- the media element is a <video> element now so the source toggle can
  carry actual video (wired up in the player commits)
- hunt gates tightened so the clean-audio search cant swap a track to
  a sped-up bootleg
- yt-dlp gets a client chain (android_vr,ios) so videos drm-blocked on
  one client resolve on the other instead of 403ing and skipping
- media-remote listener registrations catch and log their failures
the source toggle only ever swapped which audio edit streamed - there
was no video surface anywhere. an explicit switch now requests the
real mv file (?video=1, cached side by side with the audio) and the
fullscreen player shows it in place of the artwork. seeded records
stay audio-only: only a user switch opts a track in, so video-native
queue rows dont silently start pulling full videos
- one player column on the left (art, meta, seek with remaining time,
  transport, persistent volume slider), lyrics fill the rest of the
  stage. without lyrics the same column centers alone
- big bold lyric lines with a visible window: past lines dim, upcoming
  lines melt continuously into the panes bottom mask, stanza gaps come
  from the lrc break markers, the first line cues during the intro
- blur is not in the line transition list anymore - animating filter
  re-rasterizes every line each frame and starved the scroll, which
  read as stutter on every line change
- artwork crossfades on track change, and the itunes hi-res upgrade
  only applies if it resolves before the art settles. art, backdrop
  and accent never change mid-track
- accent legibility: near-greyscale covers clamp to white, dark
  accents lift in hsl (lightness up, saturation floored) so the fill
  reads on top of its own cover instead of red-on-red
- thumbless seek rail butt-joins its two segments so there is no notch
- the plain-lyrics fallback gets the same large treatment so unsynced
  tracks dont look broken
the windowed top edge fades from pure black so the menu bar boundary
disappears instead of showing a seam, and the traffic-light inset
collapses in native fullscreen where the lights auto-hide (the
reserved space read as a dead gap before the first button)
@yuvrajangadsingh yuvrajangadsingh marked this pull request as draft July 14, 2026 10:49
@NUber-dev

Copy link
Copy Markdown
Owner

@yuvrajangadsingh Wow, great work! I'll look through what's been done here and check which approach will be the easiest to maintain.

In the meantime, could you send a screenshot of how you handled the navigation and window title bar on macOS? Given that the close/minimize buttons are on the left there and are slightly different in size.

@NUber-dev

Copy link
Copy Markdown
Owner

I'm also currently working on a large number of fixes specifically for the player, track list display, and so on, and I need to check how all of this will work together so that we don't end up doing double work in the future.

@yuvrajangadsingh

Copy link
Copy Markdown
Author

thanks! heres the titlebar handling.

on mac i dont recreate the window controls at all, the OS draws its own traffic lights: native decorations with titleBarStyle overlay and a hidden title via a tauri.macos.conf.json platform overlay, so sizes and positions are always exactly what macos renders. the windows-style min/max/close cluster simply doesnt render on mac, and the nav cluster pads itself past the lights so nothing overlaps. in native fullscreen macos auto-hides the lights, so the padding collapses with them, otherwise you get a dead gap before the first button. windows behavior is untouched.

windowed (native lights, nav clear of them):

windowed

native fullscreen (lights auto hidden, nav goes flush):

fullscreen

on the double work: makes sense. beyond the port my branch already carries a bunch of player-side work (apple music style fullscreen with synced lyrics, a family of playback duration fixes, real video playback on the source toggle, media keys via mpremotecommandcenter). tell me which areas you have in flight for the player and track list and i will stay out of them or rebase around you.

and since #27 is open too, one structural thought, zero pressure: if you would rather keep this repo windows-first, i am happy to run a dedicated mac repo under any name you like, full credit to this project as the origin, and every fix that also helps windows comes back here as small focused prs. the duration clamp, the lyrics record picking and the yt-dlp client chain are already cross platform wins. and if you would rather merge mac into main, i am in for that too. whichever is easier for you to maintain.

upstream 0.3.1 removed the navigator.mediaSession action handlers when
souvlaki took over on windows, but on mac the system now playing seat
routes through wkwebviews media session, so pause from the widget or
the media keys poked the element directly (ui kept showing playing)
and next/prev did nothing at all. restore the handlers mac-only, and
sync external pause/play from the element back into the store so no
seat owner can ever desync the ui again.
the itunes lookup trusted the first search hit, which ranks by
popularity and often belongs to a different release, so a track could
wear another albums art (portrait of you showing the saroor cover).
the tracks album now rides into the lookup, results are filtered by
collection name, and a known album with no matching result keeps the
yt thumbnail instead of lying. album goes into the cache key too and
the key version bumps to flush old wrong entries.
@yuvrajangadsingh

Copy link
Copy Markdown
Author

heads up, the branch is rebuilt on your current main since the old merge went stale after the history rewrite, pr is mergeable again. also pushed a week of fixes from daily driving it: lyric timing for padded and sped up uploads, playlist pages were gluing ytm suggestions onto the real tracks, search history on the empty search page, and cleaner artist lines for tracks played from cards.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants