Distance can't pause us.
A self-hosted watch-together app. Same show. Same second. Different places.
Features • Screenshots • How it works • Getting started • Tailscale • Development
Film: Big Buck Bunny © Blender Foundation, CC BY 3.0.
One machine serves its own video library. Friends reach it over Tailscale and watch in sync from PCs, tablets and phones. When one of them buffers or steps away, the room waits.
- Your library, Plex-style. Movies, TV shows and other videos, read from local folders with Plex naming. New files are picked up on their own.
- Original quality. Video is never re-encoded. Each video is prepared once into a plain MP4, so seeking just works.
- Real sync. The server owns the room's state. Every player follows it, with small speed nudges for small drift.
- The room waits for everyone. Someone buffering, or away for more than 3 s, pauses the room. Anyone can press "Play anyway".
- Subtitles. Embedded and sidecar text subtitles, converted to WebVTT, with a shared time offset.
Old Turkish, Western and Cyrillic
.srtfiles decode correctly. - Join, don't split. Pick a video that already has a room, and you're offered that room first.
- Binge-friendly. "Next episode" switches at once and keeps the audio and subtitle language. No autoplay: the end of an episode waits for someone to press it.
- Chat. One chat per room, with replies, video timestamps and a color per person. It stays visible in fullscreen.
- No accounts. Guests pick a name. The same name on a phone and a TV is the same person. The host manages everything else from a local-only admin page.
- Works on phones. Plays inline on iPhone, with its own controls. In fullscreen, hold it upright for the chat under the video, or sideways for a bigger picture.
- Looks after itself. Starts at boot, takes a daily database backup, and deletes prepared copies nobody has watched for a while.
![]() |
![]() |
| Rooms. Who's watching, and where each room is. | Picker. A video that already has a room says so. |
![]() |
![]() |
| Welcome. A guest picks a name. No account. | Admin. Libraries, misnamed files with the fix, the cache. |
flowchart LR
subgraph Host machine
M[(Media folder<br/>read-only)] --> A[PauseTogether<br/>Go + SQLite + ffmpeg]
A --> C[(Data dir<br/>db, cache, backups)]
H[Host browser] -- "localhost:8421<br/>admin" --> A
end
G1[Guest PC] -- "Tailscale :8420" --> A
G2[Guest phone] -- "Tailscale :8420" --> A
- One Go binary with the SvelteKit app built in, running in Docker.
- Two ports. The guest port (
8420) serves the app. The admin port (8421) adds the admin API and only listens on127.0.0.1. - Tailscale is the only access control. Anyone who reaches the guest port is trusted.
Important
Every viewer streams the original file. The host's upload speed must cover the video's bitrate times the number of guests.
-
Clone the repo and copy the settings template:
git clone https://github.com/erkanvatan/pause-together.git cd pause-together cp .env.example .env -
Edit
.env:Variable What it is Default MEDIA_ROOTFolder with all your media. Mounted read-only. Must exist. ~/VideosDATA_DIRFolder for the database, cache and backups. Must exist. ~/.local/share/pausetogetherPUBLIC_BINDHost IP for the guest port. Set it to your Tailscale IP. 127.0.0.1GUEST_PORT,ADMIN_PORTHost ports. 8420,8421
Caution
Never set PUBLIC_BIND to 0.0.0.0. Docker bypasses ufw, so that opens the app on every
network the host joins, café Wi-Fi included.
-
Let Docker bind the Tailscale IP even when it starts before Tailscale does:
echo 'net.ipv4.ip_nonlocal_bind=1' | sudo tee /etc/sysctl.d/99-pausetogether.conf sudo sysctl --system
-
Start Docker at boot, so PauseTogether comes back by itself after a restart:
sudo systemctl enable docker.service containerd.service -
Build and start:
docker compose up -d
It keeps running across reboots until you stop it with
docker compose down. After that, it stays off until the nextdocker compose up -d. -
Open
http://localhost:8421on the host. Go to the admin page and add a library: a folder under your media root, plus its type. -
Let guests in: see Connecting devices with Tailscale.
Guests reach the host through Tailscale. It's a free app that links your
devices into one private network, even across different homes. No router setup, no open ports. Each
device gets a fixed address starting with 100..
You set it up once on the host. Each guest installs the app, accepts your invite and opens one link.
-
Install Tailscale and log in:
curl -fsSL https://tailscale.com/install.sh | sh sudo tailscale uptailscale upprints a link. Open it and sign in with a Google, Microsoft, GitHub or Apple account. That creates your free Tailscale account. -
Get the host's Tailscale IP:
tailscale ip -4 # e.g. 100.101.102.103Put it in
.envasPUBLIC_BIND=100.101.102.103, then rundocker compose up -d. -
Turn off key expiry for the host. By default Tailscale logs every device out after 180 days, and guests would find the server gone. On the Machines page, open the host's
⋯menu and pick Disable key expiry. -
Share the host with each guest. On the same page, open the host's
⋯menu and pick Share…. Send the guest the link, or enter their email. A share gives them this one machine, not your other devices.
- Open the share link from the host. Sign in with a Google, Microsoft, GitHub or Apple account, and accept the shared machine. This is done once per person, not per device.
- Install Tailscale from the download page, or the App Store or Google Play on phones and tablets. Sign in with the same account and turn it on. Phones ask to add a VPN: allow it.
- Open the host's address in a browser, with
http://and the port:http://100.101.102.103:8420. Pick a name, then bookmark the page.
Give every guest the same address, the Tailscale IP. The admin page shows it as the guest link, and
each room shows its own link to copy into a message. A browser remembers its name per address
(localhost, the IP, the MagicDNS name). A guest who switches address has to type their name again.
The same name makes them the same person again.
Note
Tailscale must be on while watching. On phones it's a switch in the app, and it can turn itself off. If the page won't load, check that first.
| Problem | What to check |
|---|---|
| Page won't load | Tailscale is on, on both the host and the guest device. PUBLIC_BIND is the host's Tailscale IP, and docker compose up -d ran after you set it. The address has http:// and :8420. |
| Worked before, stopped | Tailscale got turned off on the device. Or the host's key expired: see step 3 above. |
| Video keeps buffering | While the guest watches, run tailscale status on the host and find their device. direct is good. relay means traffic goes through Tailscale's servers, which is slower. Strict networks (work, school, some mobile data) often force a relay. Also check the host's upload speed. |
PauseTogether reads everything from folder and file names. It never looks anything up online, so the names have to be right. Movies and TV shows follow Plex's naming rules. A file that doesn't match is skipped and shows up on the admin page with the reason.
A library is one folder inside MEDIA_ROOT with one type: Movies, TV Shows or
Other Videos. Keep each type in its own folder:
~/Videos/ ← MEDIA_ROOT
├── Movies/ ← library, type Movies
├── TV/ ← library, type TV Shows
└── Home Videos/ ← library, type Other Videos
Libraries can't overlap. You can't add ~/Videos as one library and ~/Videos/Movies as another.
The file name needs the title and the year in parentheses. The folder is optional, and only the file name counts.
Movies/
├── Dune (2021)/
│ ├── Dune (2021).mkv
│ ├── Dune (2021).en.srt
│ └── Trailers/ ← extras: skipped quietly
├── Heat (1995).mkv ← no folder: fine
└── Alien Collection/ ← collection folder: fine
└── Alien (1979)/Alien (1979).mkv
- Editions:
Blade Runner (1982) {edition-Final Cut}.mkv. Two editions can share a folder. - Versions: text after the year is a version label:
Dune (2021) - 4K.mkv. - Other
{...}tags are ignored, like{imdb-tt1160419}.
Every episode sits in a show folder. The show's name comes from that folder, not from the file. The
season and episode come from the s01e02 in the file name.
TV/
└── Severance (2022)/ ← show folder: the show's name
├── Season 01/
│ ├── Severance (2022) - s01e01 - Good News About Hell.mkv
│ └── Severance (2022) - s01e02 - Half Loop.mkv
├── Season 2/ ← no leading zero: fine
│ └── Severance - s02e01.mkv ← year and episode title are optional
└── Specials/ ← or Season 00
└── Severance - s00e01 - Inside Lumon.mkv
- Season folders are optional. An episode can sit loose in the show folder.
- Two episodes in one file:
s01e01-e02,s01e01e02ors01e01-02. - Episode titles come after
-. Junk after the episode number (.1080p.BluRay-GROUP) is ignored. - One level only.
Show/Season 01/Disc 1/…is skipped. So is an episode with no show folder.
Warning
Episodes named by date (Show - 2024-05-01.mkv) or by absolute number (Show - 125.mkv, common for
anime) are not supported. Rename them to s01e02 style.
No rules. The file name is the title, and sub-folders become groups in the picker.
Home Videos/
├── Wedding.mp4 ← title "Wedding", no group
└── Summer 2024/
└── Beach Day.mov ← title "Beach Day", group "Summer 2024"
A subtitle file (.srt, .vtt or .ass) sits next to its video. Its name is the video's name, then
a language code, then optional flags.
Dune (2021).mkv
Dune (2021).en.srt ← English
Dune (2021).tur.srt ← Turkish: 2- or 3-letter codes both work
Dune (2021).en.forced.srt ← forced: only the foreign-language parts
Dune (2021).en.sdh.srt ← for the deaf and hard of hearing (.hi works too)
- The language code is required.
Dune (2021).srtis skipped. - No
Subs/folder. Subtitles in a sub-folder aren't read. himeans two things. First it's Hindi (.hi.srt). After a language it's the SDH flag (.en.hi.srt).
Subtitles inside the video file need no naming. They're found on their own.
These never show up in the picker:
| Skipped | Examples | On the admin page? |
|---|---|---|
| Extras and samples (Movies and TV only) | Trailers/, Featurettes/, Dune (2021)-trailer.mkv, sample.mkv |
No |
| Hidden and macOS files | .hidden.mkv, ._Dune (2021).mkv |
No |
| Movies split into parts | Dune (2021) - pt1.mkv, cd1, disc1 |
Yes |
| Anything that breaks the rules above | Dune.mkv in a Movies library |
Yes |
Note
Renaming or moving a file makes it a new video, and the old one counts as missing. A room using the old one shows "Video missing" once its prepared copy is gone too. Pick the new file there, and playback resumes where it was.
| Supported | |
|---|---|
| Containers | .mkv .mp4 .m4v .mov .avi .webm .ts .m2ts |
| Video | H.264 (8-bit 4:2:0), HEVC, AV1, VP9. Each device plays what it can decode. |
| Audio | Any. Converted to stereo AAC unless it's already AAC with up to two channels. |
| Subtitles | Text only: SRT, ASS, WebVTT, mov_text. Image subtitles (PGS, VobSub) are not. |
Everything runs in Docker through Task. You don't need Go or Node on the host.
task dev # dev stack with hot reload: http://localhost:5173
task test # all unit tests (Go + web)
task lint # golangci-lint, svelte-check
task build # build the production image
task demo # re-record the demo at the top
task screenshots # retake the screenshotsDev uses its own ports and data folder (./.dev-data), so it runs safely next to production. It
needs MEDIA_ROOT or DEV_MEDIA_ROOT set in .env. Point DEV_MEDIA_ROOT at a scratch folder when
testing file adds and deletes.
Stack: Go standard library, SQLite (modernc.org/sqlite), coder/websocket, ffmpeg. SvelteKit (Svelte 5, SPA mode) and Tailwind v4.
The full spec lives in AGENTS.md. The build order lives in docs/plan.md.
MIT, Copyright (c) 2026 Erkan Vatan.



