Skip to content

Repository files navigation

WatchSync

WatchSync

Sync the moment, not the screen.

Nobody streams anything to anybody. Everyone opens their own copy, and WatchSync keeps the play button in lockstep.

Download for macOS   macOS 15+ Notarized No account MIT


Clicking Enter Sidebar Mode: the video window resizes and WatchSync parks itself against the right edge

What it does

Screen sharing a movie is a bad way to watch a movie. It re-encodes a 4K file down to a smeary 720p, it dies when someone's Wi-Fi hiccups, and one person's laptop fan does all the work.

WatchSync goes the other way. Everyone plays their own copy at full quality, on whatever app they already use, and the app syncs only the controls. When you pause, everyone pauses. When you skip back ten seconds because someone was talking, everyone skips back ten seconds.

It figures out what you're playing by asking macOS the same question the Now Playing widget asks — so YouTube in Chrome, a local file, Spotify and Apple Music all work the same way, with no browser extension and nothing to configure per app.

Creating a party, sharing the code, and someone joining

Getting in takes six characters. Somebody creates a party and reads out a code — Q485M5. No email, no account, no invite link to chase. The alphabet skips 0, 1, I and O, so nobody has to ask whether that's a zero or a letter O over a bad mic.

Pick a name and an emoji and you're in. Your seat is held by an ID generated on your Mac, so nothing about you gets stored anywhere.

It tells you when it's lying. Sync that silently doesn't work is worse than no sync, so the party header always says which of eight states you're actually in — everyone together, drifted by nine seconds, split on play state, or watching entirely different things. Hover it and it explains itself.

Underneath, the chat narrates what the room did: orion played, zeynep jumped to 0:32, orion paused. When something desyncs you can see which pause caused it.

The status pill flipping from Play state mixed back to All in sync as action lines land in chat

Why it's good

It drives apps that have no API. Post-macOS 15.4, the private framework behind Now Playing stopped answering in-process calls, which is why most media widgets quietly broke. WatchSync reaches it the one way that still works — a bundled Perl subprocess streaming JSON events — so detection isn't a per-app integration list that rots.

It doesn't fight you. A pause that arrives while your own player is mid-buffer looks identical to a real pause from a friend. So a mismatch has to persist for 700 ms before anything happens, and when it does the app sets the absolute state rather than toggling — a toggle applied to a stale snapshot lands you in the wrong direction, which is how these things end up in a pause/play war.

Two people watching different things don't interrupt each other. Before applying anything, it checks whether you're even on the same content, matching on either bundle ID or title. Safari and Chrome on the same video still sync. Your Spotify and their movie don't.

It stays out of the frame. Sidebar mode resizes your video window and parks the panel flush against the right edge, 280 points wide, re-snapping every time you switch apps. It rides into fullscreen Spaces instead of being hidden by them. Close the window and the app drops to the menu bar and keeps syncing.

Questions people actually ask

Do we all need the same video file?

Yes. WatchSync syncs the controls, not the pixels — it never sees or sends a frame of what you're watching. Everyone needs their own copy, or their own tab on the same streaming service. Think of it as a shared remote, not a projector.

Does it work with Netflix, Disney+, YouTube?

Play and pause work anywhere macOS reports Now Playing, because they go out as a system-wide media key — the same event as the play button on your keyboard.

Seeking is fussier. In browsers it works by setting currentTime on the page's <video> element, which covers nearly every video site, but you have to let the browser accept it: Chrome → View → Developer → Allow JavaScript from Apple Events, or Safari → Develop → Allow JavaScript from Apple Events. Without that, play/pause still syncs and seek quietly doesn't.

Do I need an account?

No. There's no sign-up, no email and no password anywhere in the app. You're identified by a random ID generated on your Mac the first time you launch it, and "Clear all data" in Settings throws it away.

What happens if someone's connection drops?

Their client stops heartbeating and they fall out of the member list after three minutes. Everyone else keeps syncing. When they come back the app re-creates their seat automatically — every mutation doubles as a heartbeat, so a network blip that swept them mid-message can't wedge them out of the room.

Is any of this stored?

Barely, and not for long. A party holds a six-character code, a display name and emoji per person, one row of current playback state, and the chat. A cleanup job runs every minute: members go after three minutes without a heartbeat, and the whole party — chat included — is deleted after 30 minutes of inactivity, or a minute after the last person leaves.

Why does it want Accessibility permission?

Two things, both in sidebar mode: reading the frontmost window's frame so it can size itself next to your video, and posting system-wide media keys so a remote pause reaches whatever app is playing. macOS gates both behind Accessibility. Deny it and everything else still works — you just get a floating window instead of a snapped one.

Why isn't it on the Mac App Store?

It can't be. Accessibility, system-wide media keys, AppleEvents into other apps, and a Perl subprocess reaching a private framework are each independently disqualifying. So it ships the way Rectangle and Bartender do: a Developer ID app, signed and notarized by Apple, in a DMG. Gatekeeper opens it normally — no right-click-to-open dance.

Can I run my own backend?

Yes, and it's two steps. Deploy the functions in convex/ to your own self-hosted Convex instance, then point ConvexService.deploymentUrl at it. See Running your own backend below.

Under the hood

SwiftUI on the front, Convex on the back. Playback state is one row per party that every client subscribes to, so a pause lands on everyone's Mac over an open websocket instead of a polling loop.

The parts that were actually interesting to build:

  • Echo suppression is most of the problem. Every client both writes to and watches the same row, so your own pause comes straight back at you 200 ms later looking exactly like a peer's. Writes carry an originating device ID and an event ID, inbound rows older than your last push get dropped, and there's a 700 ms window after every push where nothing inbound applies at all. Without those three, one pause ping-pongs forever.
  • Backward seeks have to survive being indistinguishable from lag. Rewinding ten seconds and simply being ten seconds behind look the same in a snapshot, so the engine extrapolates a peer's position forward from when they captured it and only seeks past 3 s of real drift — while the chat's action lines diff deliberate jumps against elapsed time, so "rewound to 1:58" only appears when someone actually dragged the scrubber.
  • A panel that survives fullscreen. .canJoinAllSpaces + .fullScreenAuxiliary set once at snap time, not per tick — mutating them on a timer makes the panel flicker across Space changes — plus an explicit raise of the target's fullscreen window, since activating an app without that lands you on the wrong Space when a windowed and a fullscreen window both exist.

Build it

brew install cmake                      # one-time; the build phase needs it
open WatchSync.xcodeproj                # ⌘R — first build fetches the MediaRemote adapter

Or from the command line:

xcodebuild -project WatchSync.xcodeproj -scheme WatchSync \
  -configuration Debug -derivedDataPath build build

The "Install MediaRemote Adapter" build phase clones and builds ungive/mediaremote-adapter on first run, then copies the framework and Perl script into the app bundle. People running the DMG never see any of it.

Running your own backend

The app ships pointed at a hosted Convex instance and works out of the box. To run your own:

cp .env.example .env.local              # fill in your URL and admin key
npm install
npx convex deploy                       # from the repo root, not from convex/

Then change deploymentUrl in WatchSync/Sync/ConvexService.swift to your instance and rebuild.

Say hi

Found a player it can't drive, or a way to make two Macs disagree — open an issue with the app you were using and what the sync pill said, or email info@walhallaa.com. Every message gets read.

MIT licensed. Do what you like with it.


Built by Murat Can Koç

About

Watch-party app for macOS that syncs playback instead of streaming video. Detects whatever macOS is playing — YouTube in Chrome, a local file, Spotify — and mirrors pause, play and seek to everyone in the party over a six-character code. No accounts. SwiftUI + self-hosted Convex.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages