Plex, Jellyfin, or Build It Yourself? Why I Made CryptoFlix
Plex and Jellyfin are both good. I still built a private Netflix-style app for my home with Claude Code. It streams from my PC and a cloud storage account, with Jellyfin hidden inside it as a transcoder.
The build took six calendar days, and on day three the app was already live on my iPhone with an empty library. Plex and Jellyfin are both good, and I built my own anyway. The shopping trip took about ten minutes because two research agents did it for me.
That's the part I can't get over. When an existing product gets you most of the way and the missing piece is the piece you care about, you can just build the tool you want. AI coding agents turned that from a someday project into a week.
The Itch#
I have video files spread across the drives of a Windows PC. I also have an always-on Mac Mini that sits there doing very little. The goal was one Netflix-style place to watch from the iPhone, the iPad, and the Macs in the house, with no hauling files around by hand. When I travel, those same devices connect back to my home network over my UniFi VPN, so home is wherever the VPN is.
The setup shaped everything that followed. The Mac Mini is an M4 with 24 GB of RAM and about 40 GB free on its internal disk, so almost everything it does lives on an external drive that stays plugged in. The PC is the opposite: lots of storage, but it isn't always on. That's why the app lives on the Mini.
The session opened with a few sentences describing the idea: a locally hosted app, access to the PC's files, streaming, an amazing UI like Netflix with metadata like actors, and please ask me questions instead of guessing. Twenty minutes later (21:14, Saturday) I was already correcting its first assumption:
"can you use the mac external drive for hosting, I want to avoid using the local storage and the external drive is always plugged in to the mac and I have limited storage on the mac"
Then came the clarifying questions, and the answers turned out to be the spec. Where would I watch? "Browser on the Mac Mini, Other Macs / laptops, Phone / iPad." What about the PC being off? "I'm ok with streaming and file access not working while the pc is down but I just want that to be accounted for." Who uses it? "Just me, simple login." Should it write to the PC? "Read-only." The first message had also asked to hook a cloud storage account in through its API, and when the questions got to it, I picked browse and stream.
The v1 extras were Continue Watching, search and filters, trailers, cast pages, and a feature that tracks what has been watched.
The name was almost an afterthought. I typed "Lets call it CyptoFlix and Private" (typo and all), then settled on CryptoFlix to match the CryptoFlex brand.
What is SMB?
SMB is the protocol Windows uses to share folders over a network. The Mac Mini mounts the PC's drives over SMB, read-only, so the app can see the files without ever being able to change them. Nothing runs on the PC itself.
The Shopping Trip#
Before any code, the question was what to build against. Two research agents started at 20:55 and reported back in about ten minutes. The controlling session verified the claims that carried weight and corrected four of them, which is a good reminder that research agents are confident whether or not they're right.
Their verdict was that a custom app was needed. Here's how the candidates stacked up, with prices checked on 2026-10-03.
Swipe to see the full diagram · Tap to enlarge
Swipe to see the full diagram · Tap to enlarge
| Question | Plex (used it) | Jellyfin (tested) | CryptoFlix (built it) |
|---|---|---|---|
| Who maintains it | Plex, the company | open-source project | me, with Claude Code |
| Sign-in | Plex account sign-in, on by default once a server is claimed, LAN too; an advanced bypass exists (with caveats) | No cloud account; local server users (fits what I wanted) | One local password; one user (fits what I wanted) |
| Cost | Free at home; Plex Pass $6.99/mo, $69.99/yr, or $749.99 lifetime; remote streaming paid since 2025-04-29 (with caveats) | Free; open source, GPL (fits what I wanted) | No license fee; TMDb free, non-commercial; costs time and upkeep (with caveats) |
| Look and feel | Plex's own apps; the look is Plex's; beside its ad-supported TV (doesn't fit) | Jellyfin's own look; as shipped (doesn't fit) | Fully custom; two designs (fits what I wanted) |
| Codec handling | Server transcodes; hardware accel: Plex Pass (with caveats) | FFmpeg transcoding; free hardware acceleration (fits what I wanted) | Direct play when the browser can decode it, else hidden Jellyfin to HLS on Apple's VideoToolbox (fits what I wanted) |
| Whole-PC file access (must-have) | Library folders only; no whole-drive browser (doesn't fit) | Library folders only; no whole-drive browser (doesn't fit) | Every PC drive; read-only file browser (fits what I wanted) |
| When the PC is off (must-have) | Not tested here; no head-to-head test (not tested here) | Keeps items offline if its library roots are deep enough (spike result) (with caveats) | Badged offline, no hangs; about 45 s worst case; cloud copies keep playing (fits what I wanted) |
A word on where each column comes from. I tested Jellyfin hands on during the spike. Plex I know as a user: I've used it before, and friends still share their Plex servers with me today. Each Plex fact below links to its source, checked on 2026-10-03.
What Plex gets right, and where it missed me#
Plex gets a lot right. The apps run on nearly everything, and sharing a library with friends is the thing it does best. That's why my friends run it.
For this build, though, it missed in five ways:
- It signs you in to someone else's cloud. Plex's own words: "When your Plex Media Server is claimed or signed in to a Plex account, then all access to the server will require authentication by default." An advanced setting lets listed networks in without auth, with a security warning attached. So it's sign-in by default, even on the LAN. That dependency showed on 2026-07-14, when a Plex outage reportedly hit even local playback for some users. I wanted one local password and nothing outside the house between me and my own files.
- The app isn't only my library. Plex's free ad-supported movies and shows, Live TV, and Discover sit beside your own libraries, and Plex's plans page says a Plex Pass doesn't remove those ads. I wanted an app that only knows about my files.
- It's social by design. Sharing is why my friends use Plex, and it's a real strength. It also brings a social layer I don't need. In November 2023, Plex's Discover Together emails, which included friends' watch activity, caught some users off guard. CryptoFlix has one user, so there's nobody to overshare with.
- The rules are Plex's to change. Remote streaming of personal video stopped being free on 2025-04-29, a change Plex announced that March. You now need a Plex Pass or a Remote Watch Pass ($2.99/month or $29.99/year on Plex's plans page). It reaches me as a guest, too: watching a friend's server from my house is free only if that friend has a Plex Pass. Hardware-accelerated transcoding requires an active Plex Pass for the server owner. The remote rules don't touch a LAN-only app, but the transcoding one does. Either way, someone else's pricing page decides what the server can do.
- The account is a target. Plex asked users to reset their passwords after security incidents in August 2022 and September 2025, though the passwords were stored hashed both times. CryptoFlix's one password lives on the Mac Mini, not in someone else's user database.
None of that makes Plex bad. A company has to pay for a cloud account system somehow, and if you want to share a library with friends, Plex is still the easy answer. But the target here was a single-user app that nothing on the internet can reach: my own devices get to it from the LAN at home, or over my VPN into the LAN when I'm away. It does make outbound HTTPS calls, to TMDb and the cloud storage API, but sign-in stays local. A server that signs you in to someone else's service by default is the wrong starting point for that.
What Jellyfin gets right, and where it missed me#
Jellyfin is free software under the GPL, with, in its own words, "no tracking, phone-home, or central servers collecting your data." It transcodes with FFmpeg, supports hardware acceleration (Apple included), and has official clients on most platforms. If my only requirement had been "stream my files at home," I'd have installed it and gone to bed.
Three things got in the way. First, Jellyfin wants one content type per library folder. The PC's main watched folder is a mixed bag of videos and random other files. Second, there's no browser for an entire drive, which was the "access to the PC's files" part of the original ask. Third, the design notes for running it as-is say it plainly: "the look would be Jellyfin's." I wanted the look to be mine.
Infuse, the footnote#
Infuse is a player rather than a server. It connects to SMB shares or to Plex and Jellyfin, plays MKV and MP4 on the device, and costs $1.99/month for Pro. It's Apple-only, though, and the research summary says it has "no custom UI." It also means an app per Apple device instead of a page that opens in any browser.
Check pricing pages before you plan
Plex's terms changed in 2025, and the lifetime price jumped from $249.99 to $749.99 on 2026-07-01. Every Plex, Jellyfin and Infuse price in this post came from the vendors' own pages on 2026-10-03. Re-check before you rely on them.
None of the three had what mattered most. I wanted read-only browsing of every PC drive. I also wanted an app that says "the PC is off" plainly and keeps everything else working. I'm not claiming Plex or Jellyfin fail at that. They weren't tested offline head to head. The spike found that Jellyfin keeps every library item, ids unchanged, during an offline refresh when its library roots are deep enough. It's CryptoFlix's promise, and I wanted to own the promise.
The Twist: I Hid Jellyfin Instead of Replacing It#
The research turned up a surprise: browser playback, not bandwidth, was the problem. Safari, and therefore every browser on iOS, can't play a lot of what's sitting in a typical video folder.
| In the file | Safari plays it? | What CryptoFlix does |
|---|---|---|
| MKV container | No (Chrome does) | Remux into HLS, no re-encode |
| DTS audio | No (Chrome neither) | Transcode to AAC |
| SRT, ASS, PGS subtitles | No | Convert to WebVTT or burn in |
| H.264 video, AAC, AC3, E-AC3 audio | Yes | Direct play |
What is HLS?
HLS (HTTP Live Streaming) is Apple's format for streaming video as a series of short segments plus a playlist. Safari plays it natively, which makes it the easy target for anything the browser can't play directly.
Claude laid out three approaches. A was a custom app on top of a hidden Jellyfin. B was fully custom, which it rejected because video conversion and subtitle edge cases "are weeks of work, and iPhone playback is where it will hurt." C was Jellyfin as-is plus a companion app, which it rejected with the same line as before: "the look would be Jellyfin's." I picked A at 21:04 that night.
So the shape is this. CryptoFlix owns the catalog and identification. It owns sign-in too. Search, browse, and every playback session also belong to it.
Jellyfin runs headless on 127.0.0.1 only. It has no web client, and its metadata fetchers are off. Its network config is reinstalled from a pinned template on every start.
When a browser can decode a file, CryptoFlix plays it straight from the read-only mount. When it can't, Jellyfin remuxes or transcodes to HLS (two at once, at most). CryptoFlix then rewrites every playlist line into opaque /stream addresses. No Jellyfin key ever reaches the browser. The same proxy later took on the cloud copies, whose playlist and segment URLs each carry an access token, and none of those reach the browser either.
Swipe to see the full diagram · Tap to enlarge
The spike put numbers on why hardware transcoding matters. Software HEVC decoding cost 8.68 seconds of CPU per minute of video, versus 0.70 seconds with VideoToolbox. The spike's first three-second segment came back in 0.76 seconds. Video conversion is solved, and Jellyfin has years of edge-case fixes I don't want to rediscover on my own phone.
"Just Build It"#
Here's what made six days possible. The planning session worked as a controller: it researched, asked me questions, dispatched more than 200 subagents, reviewed their work, and deployed.
Research and clarifying questions came before any code, and by 21:29 on the first night I'd approved a design. By 21:55 the private repo held the plan, the docs, a spike runbook, and the first build plan. Seven go/no-go gates then ran on the real Mac and the real PC that night, in parallel with the first build phase. They exist to kill risky assumptions early, while killing them is still cheap. One of them found the bug in the dead-share story below.
I steered, and I wasn't always graceful about it. On Monday night I wrote: "I think you need to slow down and tell me exactly what I need to do on the pc with URLs steps, I have claude code on pc so if it's easier to use a prompt maybe I can do this that way but I'm really confused now." On Wednesday: "Get me to production, continue on with all the steps, troubleshoot issues and commit and deploy a fully production cryptoflix. Use your best judgement." And that evening: "Skip the test and continue. I'm ok with synthetic tests."
That Monday message points at a funny arrangement: two machines and two Claude Codes. The one on the PC ran PC-side steps from prompts the controller printed for me. The elevated setup script ran in a window Claude couldn't see, because it prints a freshly generated password, and that password belongs to me and not to a transcript.
An AI-assisted matching command, which handles the files the automatic matcher can't place and only applies what I confirm, is the one other piece worth mentioning here. Its security story gets its own post.
My job in all of this was product owner: say what I wanted, answer questions, push back, and test on the devices I actually use. The agents wrote, reviewed and shipped the code. Not long ago, "Plex doesn't quite fit" would have ended with me living with Plex. This time it ended with the app I wanted running on my phone in under a week.
MVP First#
An empty app is a perfectly good milestone if it's live. The first live moment came Monday night at 22:59 (about 50 hours after my first message). A local DNS record was in, the app was open on my iPhone, and the message that followed read: "I added the local dns record and accessed from my iphone but there was no files there." That was the correct result. The catalog backend wasn't deployed yet.
| Day | Milestone |
|---|---|
| Sat | First message 20:54, approach chosen 21:04, design approved 21:29. At 23:09, an iPhone playback test: "Iphone testing was good, no issues" |
| Sun | Spike findings committed; foundation (auth, PC monitor, setup scripts) merged |
| Mon | Deploy: launcher app, SMB mounter, Jellyfin bootstrap. Live with an empty library at 22:59 |
| Tue | Catalog backend deployed |
| Wed | Browse UI, playback server, web player with resume and Continue Watching, search, person pages, file browser |
| Thu | The cloud storage source (migration, 0 rows lost), browse and binge, then the redesign |
Swipe to see the full diagram · Tap to enlarge
A real catalog, to be fair, dates to early Wednesday, because identifying files needed my TMDb token and it only got stored around 02:07 that morning. Each increment then deployed on its own. The catalog came first (TMDb scoring, with uncertain matches going to an Unmatched queue). Browse followed (a phone-first hero, poster rows, title pages), then playback (marked watched at 90%, Continue Watching, History), then search and the file browser.
The last increment came from actually using the thing. On Thursday morning I wrote a long feature request: browse by genre and sub-genre, "if you like this" suggestions, separate Movies and Shows, and a "continue to next episode" with autoplay. That became Movies and Shows tabs, genre pages built from TMDb keywords, and a More like this row.
More like this looks in the library first, then TMDb. A shared keyword or a top-billed actor adds ln(1 + N/df) to the score. Rarer tags count for more. Autoplay got a 10-second countdown and an "Are you still watching?" prompt after three in a row.
Mid-phase, from a screenshot, I wrote that I couldn't click the genres on a details page. Now you can tap science fiction and get more of it.
The Cloud Half: Still Playing With the PC Off#
The PC isn't CryptoFlix's only source. It also browses and streams from a cloud storage account.
In the ask from day one#
That account was in my first message, so it was never a stretch goal. At 21:27 I answered one more question about it, "Yes, CryptoFlix identifies all (Recommended)," so cloud files go through the same identification as PC files and land in one catalog.
I'd said I could live without streaming while the PC was down, as long as it was accounted for. The design I approved that night went further: PC-only titles get an offline badge, cloud titles keep playing, and nothing hangs. A cloud play never touches the PC or calls Jellyfin.
The day-one research found no existing project that combined this kind of cloud storage account, a local library and a Netflix-style UI. Jellyfin has no support for this provider, one more reason approach A won.
Here's that offline promise on the demo library's Movies wall.
Screenshots in this section use the demo library, and the provider's name is shown generically as "Cloud storage".
Linking and syncing#
The integration waited until Wednesday evening, because it needed me to register an app with the provider's API first. At 19:53 I asked what connecting would take. The answer was a one-time code, so no token would pass through the chat.
Settings shows the code, I approve it on the provider's site, and CryptoFlix polls every 5 seconds, giving up after 10 minutes since the provider documents no expiry. My password never reaches CryptoFlix, and a late token for a cancelled link gets revoked, never logged. The token is sealed with AES-256-GCM in SQLite and only ever travels in an Authorization header to fixed API endpoints.
Once linked, the account gets this card in Settings.
Why list the whole account every time? A server the internet can't reach can't receive webhooks, and the provider's change feed reports new files but never renames or deletions. So CryptoFlix lists everything at connect, every 10 minutes while connected, and on Sync now. A listing error, a 429 or one of CryptoFlix's own caps marks a sync incomplete, and only a complete sync may mark a file missing.
All API calls share one rate limit, 2 requests a second with bursts of 10, because the provider publishes no fixed numbers. A 429 pauses every caller, queued ones included.
One catalog, any copy plays#
A cloud file is a row in the same sources table as a PC file, with an origin marker, the provider's file id, and no PC folder. A separate table was rejected because progress, sessions, search, Unmatched and history would each have needed a parallel path. Both kinds land on the same title, and as the architecture doc puts it, "There is no merge step." Each file wears a PC or cloud badge.
Availability is per side, tracked as Reach { pc, cloud }. A PC file can play while the PC is online and the last poll saw it. A cloud file can play while the account is connected and the last complete sync saw it. A title, a Continue Watching item or a history entry plays if any visible copy at the same placement can (the same movie, or the same episode). Otherwise it says why: PC offline first, then not connected, then missing.
Swipe to see the full diagram · Tap to enlarge
Play uses the copy I picked if it can, otherwise a sibling, PC first, and next-episode autoplay follows the same order.
Playback through the same proxy#
The spike had already found that the provider serves HLS only for files it has converted, with an access token in every playlist and segment URL. Its CORS rules would have let the browser play straight from the provider, but only by holding those tokens. So the cloud goes through the same rewriting proxy as Jellyfin, plus a conversion flow.
A cloud play gets its own cap of three sessions, separate from Jellyfin's two. If the file still needs converting, CryptoFlix asks the provider to convert it and answers 202 "preparing" with a percentage, and the player shows Preparing and checks back every 5 seconds. Once it's ready, the server fetches the master playlist with the token in a header and rewrites every URI into a /stream address with a 128-bit random id, mapped to the real URL in server memory, so no provider token reaches the browser.
A 401 from the API means the account needs reconnecting: sync stops, cloud sessions end, and cloud files read "not connected."
What broke on Thursday#
The schema change came first, on Wednesday night, and nearly cost me my watch history (that's in the migration section below). The rest came up on Thursday. At 03:25 the final review found that a failed first link would fail closed, which is right, but the log couldn't say why, and one off-schema item in a listing emptied the whole sync. The fix parsed items one at a time and logged the paths of failing fields, never their values:
// A2: names the issue PATHS a zod parse failed on, never the values it saw.
function issuesOf(error: z.ZodError): string {
return error.issues.map((i) => `${i.path.length > 0 ? i.path.join('.') : '(root)'}: ${i.message}`).join('; ');
}
The deploy went out at 06:37, I linked the account at 06:42, and two minutes later I reported:
"I did link [the cloud account] and hit the sync now and it shows 0 files, 0 titles. Is that expected. Part A is done"
It wasn't. Because parsing failed closed, nothing had been marked missing, and that 03:25 log line named the cause on the first try:
cursor: Too big: expected string to have <=4096 characters
The provider's real pagination cursor is longer than the 4,096 characters the plan had allowed. The hotfix raised the cap to 256 KiB, with a test, and was committed at 06:45:06, about a minute after my report. After a restart the next sync was complete, and the log held no token text.
Log the path, never the value
When a parser rejects data from someone else's API, log which field failed, never the value. The value might be a token.
My own testing that morning proved less than I thought. At 06:52 I said I'd tested a few cloud videos, but Play prefers the PC copy while the PC is online, so those were PC plays. A real cloud play of something with no PC copy stayed on my list of open checks.
Thursday evening brought three more, the first from a case the spike never checked.
| Thu | What broke | Fix |
|---|---|---|
| 21:39 | Some browser-ready videos showed "cannot stream this file." CryptoFlix asked for a conversion they didn't need, and the provider refused with a 400 | On that refusal, try the HLS master directly and remember the file is streamable |
| 22:18 | I chose to hide titles with no copies left, but Disconnect marked every cloud file unavailable, so disconnecting would have hidden every cloud-only title | Disconnect no longer touches availability; the next complete sync reconciles real deletions |
| 22:38 | Deleting the watched copy emptied Continue Watching even when another copy could play | The item stays while any copy at that placement can play, and a 404 at play time refreshes the catalog |
Things That Went Sideways#
The launcher app#
A LaunchAgent that started the server got "Operation not permitted" on the external drive. A bare launchd process has no privacy grant. Giving Homebrew's node access would have opened the drives to every background Node script I'll ever run. Full Disk Access was a bigger hammer than the job needed.
What is TCC?
TCC is the macOS privacy layer that decides which apps may touch protected files. It attributes each access to the responsible app. A background process with no app around it has nothing to approve.
The fix was a small launcher app: a 72 KB ad-hoc-signed Swift supervisor. Its child processes inherit its privacy attribution, so one grant covers Node and Jellyfin. The catch is that ad-hoc signatures are hash-based. Every rebuild of the launcher needs a fresh approval in System Settings.
A dead share froze a healthy server#
This one I'd have missed without the spike. The PC wasn't getting powered off for tests ("I'm not turning off the pc, do you best to validate synthetically."), so the controller faked a dead PC: a TCP relay in front of the PC's SMB port, which it then froze. Node uses 4 libuv threads by default for file work. Eight hung SMB reads were enough to block an unrelated local file read for over 150 seconds. Meanwhile the timers and health checks kept answering "fine."
Alive outside, frozen inside
A health check that never touches the filesystem can't see this failure. For a server whose job is reading files, the probe has to do a small real read, because a single hung network mount can eat a whole thread pool.
Swipe to see the full diagram · Tap to enlarge
The fix had two parts. One is UV_THREADPOOL_SIZE=64 in the launch script, after which the same read took 1 ms. The other is a forced unmount on the first failed port-445 probe, which frees the hung calls in about a second.
export UV_THREADPOOL_SIZE=64
A frozen relay still completes TCP handshakes, so a "PC down" test meant killing it outright. In the deploy test, CryptoFlix flipped to offline in 16.0 seconds, with about 45 seconds as the worst case.
The migration that nearly erased my watch history#
Adding the cloud storage source meant making sources.folder_id nullable, because a cloud file has no PC folder, and SQLite does that by rebuilding the table.
Before any code touched real data, two plan writers tested a scratch copy of the database and found two things. Foreign keys were on during migrations. The migration's own PRAGMA foreign_keys=OFF is a no-op inside drizzle's transaction. So DROP TABLE sources cascaded and emptied watch_progress and playback_sessions. In the scratch copy, my watch history was gone, from a migration that ran without a single error.
function migrateKeepingRows(sqlite: Database.Database, db: Db): void {
sqlite.pragma('foreign_keys = OFF');
migrate(db, { migrationsFolder: MIGRATIONS_DIR });
const violations = ForeignKeyViolationsSchema.parse(sqlite.pragma('foreign_key_check'));
if (violations.length > 0) { /* throws, naming the first violation */ }
sqlite.pragma('foreign_keys = ON');
}
Swipe to see the full diagram · Tap to enlarge
The runner turns foreign keys off before the migration starts, checks foreign_key_check afterward, and only then turns them back on. It went to production at 06:37 on Thursday with a rollback copy and a lost-row check by id, and no rows were lost.
Test migrations on last version's data
Run a migration against a real database from the previous version before it goes near production. A fresh, empty database will never show you a cascade.
Keeping iPhone autoplay alive#
WebKit's own blog says it best: "Since before your sun burned hot in space and before your race was born, Safari on iOS has required a user gesture to play media in a <video> or <audio> element." The gesture belongs to the element. If every episode got a fresh <video>, every autoplay on the iPhone would wait for a tap.
The ruling was that the next episode plays in the same Vidstack player and the same <video>. A function called swapTo ends the old stream session and starts the next, and only the source changes.
Swipe to see the full diagram · Tap to enlarge
If the first autoplay gets refused, a Start playing button appears, and that one tap carries a whole run of episodes.
It took three review rounds. First came a race that sent the player into the next episode after the page had been left. Then the first fix introduced a bug that only showed up under React StrictMode's development double-mount. Last, Vidstack fires pause before it sets state.ended. That broke an assumption the code made about order. I'll admit I deferred my own on-device checks for this phase in favor of browser tests on Chromium and iPhone WebKit, and I never marked them done.
The Redesign: CryptoFlex "Cyber Editorial"#
The redesign was the last thing to ship in those six days, and it happened on purpose. The commits run from 22:11 to 23:48 on Thursday night. Before that, the app looked like a competent Tailwind app with an amber accent, rounded shadowed cards, and system fonts. It worked.
The brief was to adopt the Cyber Editorial design system from this site. That means near-black blue surfaces, one teal accent, hairline borders, and 4 px radii. Space Grotesk handles UI and headings, Source Serif 4 italics handle ledes, and JetBrains Mono handles labels and numbers. The accent, oklch(0.74 0.16 192), is the same teal as the --accent in the editorial redesign post. That post is where the system came from.
The original design stays. Each page has a classic version and a new one, picked per browser and remembered in localStorage, defaulting to new. The choice never reaches the server, so the iPhone and the Mac can differ.
Screenshots use a demo library of openly licensed films. Poster credits, all via Wikimedia Commons: Big Buck Bunny, Sintel, Tears of Steel, Charge, Spring, Sprite Fright, Elephants Dream and Cosmos Laundromat are Blender open movies under CC BY 3.0 or 4.0; The General and Metropolis are public domain.
The shared components were re-themed, not rewritten. Under html[data-design="next"], the classic token names point at new values:
html[data-design="next"] {
--color-brand: var(--color-cf-accent);
--radius-card: 4px;
--shadow-card: none;
--font-sans: var(--font-cf-display);
}
Swipe to see the full diagram · Tap to enlarge
Dialogs, banners, the settings forms, season and file lists, and the player's chrome follow the new design with their behavior untouched. The watch page is shared by both designs.
The new layout adds a sidebar on wide screens and a five-tab bar on phones (Home, Movies, Shows, Search, Library). Home is a "Tonight" dashboard, and Movies is a poster wall with a quick-look sheet.
Testing caught two real problems. The first browser run failed 64 tests in both designs, all on Content Security Policy violations. Vite had inlined a small font subset as a data: URL. A lazy chunk had also switched zod's JIT back on. The fix went into the build config, with a production-build test, rather than loosening the policy.
The second problem came from the iPhone WebKit tests. They found Home scrolling sideways at 375 px. minmax(0, ...) grid columns cured it.
Some things were deferred on purpose. Classic sub-components inside the new pages are re-themed, not redesigned, and a synced preference would need a server setting and a migration, so it can wait.
Useful Before Pretty#
The first UI used about six plain-Tailwind components, on the reasoning that six primitives don't justify Radix, cva and tailwind-merge in a phone-first, single-user app. A "Play trailer" button replaced an autoplaying hero because iOS blocks those.
I'm an Agile engineer by habit: MVP first, continuous improvement after. UX and UI are features to me, not the product. A beautiful app that can't play my files is a screensaver. That's why the redesign came last, sat behind a toggle, and left the classic design working.
What Owning It Costs#
Building your own means maintaining it, including the hidden Jellyfin underneath.
Pin the hidden dependency
Jellyfin's API broke in 12.0, so CryptoFlix runs a pinned 12.1.x. Any bump is an upgrade project, not an auto-update.
The cloud half is a dependency I can't pin at all. Two of Thursday's bugs came from undocumented API behavior that only a live account could show: a pagination cursor longer than the plan allowed, and a refusal to convert a file that was already browser-ready.
It's also not a Plex or Jellyfin replacement for anyone else. CryptoFlix is single-user, plain HTTP, and reachable only from my LAN (in person or over my VPN) on purpose, which is a documented choice and also a decision I'm going to be asked about. That's the next post: "It's Only on My Home Network" Is Not a Threat Model. One bug in it is a status route that returned 200 with no session because the guard checked the raw URL while the router matched the decoded one.
After that comes The Folder Name That Gave Orders: Prompt Injection From Home Lab to Enterprise, about a command whose "ask me first" rule lived only in prose, and What 202 Subagents Taught Me About Code Review, on the method behind the controller. For an earlier "just build it" story, about this website instead of a home app, see 7 Days, 117 Commits.
The next launcher rebuild will need a fresh approval in System Settings, and until I click it, the app can't see the drives.





Comments
Subscribers only — enter your subscriber email to comment