Skip to content
Screenfin

Installation

Install the server, pair your devices, and get the apps, in the order you will do it.

Before you start

Have these ready:

  • Docker, with Compose.
  • A running Jellyfin. The container and your devices must both be able to reach it.

Only want solo playback? Skip to Get the apps. You can add the server later.

Docker Compose

  1. Save compose.yaml in a new folder

    Make a folder for the server, such as screenfin, and save this in it as compose.yaml. It runs the published image, ghcr.io/screenfin/screenfin-server. Every setting is a line in .env, and docker compose up -d applies a change.

    compose.yaml
    name: screenfin
    
    # One container — the relay and the web client.
    
    services:
      screenfin-server:
        image: ghcr.io/screenfin/screenfin-server:${SCREENFIN_VERSION:-latest}
        restart: unless-stopped
        ports:
          # PORT may carry a bind address: 127.0.0.1:8484 is loopback, for a proxy on
          # this host. Unset, it is 8484 on every interface, which televisions on a
          # ws:// LAN address need.
          - '${PORT:-8484}:8484'
        environment:
          JELLYFIN_URL: ${JELLYFIN_URL:?set JELLYFIN_URL in .env}
          ALLOWED_ORIGINS: ${ALLOWED_ORIGINS:-https://${APP_DOMAIN:?set APP_DOMAIN in .env}}
          ADVERTISED_URLS: ${ADVERTISED_URLS:-wss://${APP_DOMAIN:?set APP_DOMAIN in .env}/v1/ws}
          # Behind a proxy this must name it, or every client shares one address.
          TRUST_PROXY: ${TRUST_PROXY:-false}
          MAX_ROOM_PARTICIPANTS: ${MAX_ROOM_PARTICIPANTS:-16}
        volumes:
          # The identity key and the live rooms — back this up.
          - relay-data:/data
        # The process writes /data and nothing else, needs no capabilities, and a
        # runaway is a container restart rather than the host's memory.
        read_only: true
        tmpfs:
          - /tmp
        cap_drop:
          - ALL
        security_opt:
          - no-new-privileges:true
        mem_limit: ${MEM_LIMIT:-512m}
    
    volumes:
      relay-data:
    
  2. Save .env beside it

    .env
    JELLYFIN_URL=http://192.168.1.10:8096
    APP_DOMAIN=screenfin.example.com
    PORT=127.0.0.1:8484

    JELLYFIN_URL is your Jellyfin as the container reaches it — a LAN address or another container’s name, never localhost, which is the container itself. APP_DOMAIN is the hostname people will open Screenfin at; the browser and socket addresses derive from it. Both are required. PORT keeps the server on this machine only until you choose how people reach it. Everything else has a default, listed under Settings.

  3. Start it

    docker compose pull
    docker compose up -d
    docker compose logs -f screenfin-server

    The server keeps its identity key and live rooms on a volume named relay-data — the one thing worth backing up.

Reaching it

Choose how people reach the server.

On your home network

Open the port to your network and use the machine’s own address. Add these lines to .env, replacing its PORT, with 192.168.1.20 as the machine running Docker:

.env
PORT=8484
ALLOWED_ORIGINS=http://192.168.1.20:8484
ADVERTISED_URLS=ws://192.168.1.20:8484/v1/ws

With a hostname and HTTPS

Put any reverse proxy in front and forward everything on the hostname to the container, WebSockets included:

https://screenfin.example.com/*  →  127.0.0.1:8484

Then name the proxy in TRUST_PROXY, so each visitor counts as themselves rather than all of them as the proxy. For a proxy on the same machine, that is the Docker network’s gateway:

docker network inspect screenfin_default -f '{{(index .IPAM.Config 0).Gateway}}'
# then in .env:
TRUST_PROXY=172.18.0.1

Check the route answers:

curl http://127.0.0.1:8484/healthz             # on the host
curl https://screenfin.example.com/healthz     # through the proxy

Port 8484 speaks plain HTTP, so keep it off the internet. Using Traefik? The next section has a ready setup; for other proxies, see the repository’s reverse-proxy notes.

Traefik (optional)

Only if Traefik already runs on this machine. Replace your compose.yaml with this one: the lines marked # Traefik, and the labels under them, are the only change.

compose.yaml
name: screenfin

# One container — the relay and the web client.

services:
  screenfin-server:
    image: ghcr.io/screenfin/screenfin-server:${SCREENFIN_VERSION:-latest}
    restart: unless-stopped
    ports:
      # PORT may carry a bind address: 127.0.0.1:8484 is loopback, for a proxy on
      # this host. Unset, it is 8484 on every interface, which televisions on a
      # ws:// LAN address need.
      - '${PORT:-8484}:8484'
    environment:
      JELLYFIN_URL: ${JELLYFIN_URL:?set JELLYFIN_URL in .env}
      ALLOWED_ORIGINS: ${ALLOWED_ORIGINS:-https://${APP_DOMAIN:?set APP_DOMAIN in .env}}
      ADVERTISED_URLS: ${ADVERTISED_URLS:-wss://${APP_DOMAIN:?set APP_DOMAIN in .env}/v1/ws}
      # Behind a proxy this must name it, or every client shares one address.
      TRUST_PROXY: ${TRUST_PROXY:-false}
      MAX_ROOM_PARTICIPANTS: ${MAX_ROOM_PARTICIPANTS:-16}
    volumes:
      # The identity key and the live rooms — back this up.
      - relay-data:/data
    # The process writes /data and nothing else, needs no capabilities, and a
    # runaway is a container restart rather than the host's memory.
    read_only: true
    tmpfs:
      - /tmp
    cap_drop:
      - ALL
    security_opt:
      - no-new-privileges:true
    mem_limit: ${MEM_LIMIT:-512m}
    networks: [proxy] # Traefik
    labels: # Traefik
      traefik.enable: 'true'
      traefik.http.routers.screenfin.rule: Host(`screenfin.example.com`)
      traefik.http.routers.screenfin.entrypoints: websecure
      traefik.http.routers.screenfin.tls: 'true'
      traefik.http.routers.screenfin.tls.certresolver: letsencrypt
      traefik.http.services.screenfin.loadbalancer.server.port: '8484'

volumes:
  relay-data:

networks: # Traefik
  proxy:
    external: true

Change proxy to the name of Traefik’s Docker network, websecure and letsencrypt to your entrypoint and certificate resolver, and the host to your APP_DOMAIN.

Traefik reaches the container over that network, so TRUST_PROXY is the network’s subnet:

docker network inspect proxy -f '{{(index .IPAM.Config 0).Subnet}}'
# then in .env:
TRUST_PROXY=172.22.0.0/16

WebSockets pass through Traefik with no extra setup.

Pair your devices

Every time it starts, the server prints a branding mark. Add that line to Jellyfin so every device — phone, tablet, television, browser — can find the server and verify it.

  1. Take the line from the log

    docker compose logs --no-log-prefix screenfin-server | grep '^/\* screenfin' | tail -1
  2. Paste it into Jellyfin

    As a Jellyfin administrator, open Dashboard → General → Branding → Custom CSS, paste the line at the end, and save.

  3. Open the web client

    Go to https://screenfin.example.com, or the home-network address. On first visit it asks which Jellyfin to use.

The mark is the whole of the pairing: paste a fresh line whenever an address changes, and know that removing it un-pairs every client on its next launch.

Televisions on your network can connect directly as well: add the LAN address after the first one in ADVERTISED_URLS, for example wss://screenfin.example.com/v1/ws,ws://192.168.1.20:8484/v1/ws, and set PORT=8484. Skip this if your proxy runs on this same machine and trusts the gateway: with the port open, anyone on your network could pass as the proxy.

Get the apps

Install Screenfin from the App Store (iPhone, iPad, Apple TV) or Google Play (Android TV, phones, tablets). Sign in with your Jellyfin address and your Jellyfin credentials — there is no Screenfin account. On a television, Quick Connect lets you approve the sign-in from your phone instead of typing a password with a remote.

If you paired the server, party controls appear after sign-in. There is nothing else to configure in the app.

Recommended plugins

Screenfin works with a plain Jellyfin install. Two Jellyfin plugins turn on features in the apps; install them on your Jellyfin server, not on the Screenfin server, and every app picks them up with nothing to set in Screenfin.

Intro Skipper
defaultrecommended
Skip Intro, and intro and outro markers in the player. Adds its own plugin repository.
Open Subtitles
defaultfor subtitle search
Find and download subtitles while watching, in the iOS, Apple TV and Android apps. From Jellyfin’s own catalog.
  1. Intro Skipper

    In Jellyfin, open Dashboard → Plugins → Repositories, add https://intro-skipper.org/manifest.json, then install Intro Skipper from the catalog and restart Jellyfin. It analyses your episodes in the background; Skip Intro appears on each one once it has been analysed.

  2. Open Subtitles

    Install Open Subtitles from Dashboard → Plugins → Catalog, restart Jellyfin, and sign in to a free opensubtitles.com account in the plugin’s settings. Subtitle search in the apps is shown to Jellyfin accounts allowed to manage subtitles.

Tested with Intro Skipper 12.0.4 and Open Subtitles 25.0 on Jellyfin 12.0. Both plugins are made and supported by their own authors, not by Screenfin.

Update

docker compose pull
docker compose up -d

The relay-data volume is unchanged by an update, so the identity key and the pairing survive it.

Settings

Beyond the two required lines, every setting has a default. More on each one is in the repository’s README.

PORT
default8484
The host port, with the address it listens on.
TRUST_PROXY
defaultfalse
Your reverse proxy’s address or subnet, as the container sees it. Never a number or true.
ALLOWED_ORIGINS
defaulthttps://APP_DOMAIN
The browser addresses allowed to connect. Set it for plain http or a LAN address.
ADVERTISED_URLS
defaultwss://APP_DOMAIN/v1/ws
The socket addresses the branding mark names, first choice first.
MAX_ROOM_PARTICIPANTS
default16
The largest party the server accepts, 1–100.
MEM_LIMIT
default512m
The container’s memory ceiling. Past it the container restarts, not the host.
SCREENFIN_VERSION
defaultlatest
The image tag. Pin a release such as 1.0.0, or let latest follow every release.

If something is off

The page loads but parties never connect
The proxy is not forwarding WebSocket upgrades on /v1/ws, or is closing idle connections. The browser’s network tab shows the proxy’s answer.
Sign-in fails in Screenfin but works in Jellyfin
The app signs in to Jellyfin directly from the device, so a LAN address only works on that LAN. AUTH_UNAVAILABLE in the server’s logs means the container cannot reach JELLYFIN_URL.
Everyone is refused new connections at once
A proxy is in front and TRUST_PROXY does not name it. Set it as in Reaching it.
The socket is refused with 4006
The address in the browser does not match ALLOWED_ORIGINS exactly — scheme included, no trailing slash.
The health check fails through the proxy
If the check in Reaching it answers on the host but not through the proxy, the proxy route is the problem. docker compose logs screenfin-server has the rest.

Still stuck? Open an issue on screenfin/community with your SCREENFIN_VERSION and which client, or write to [email protected].

Back to the front