This is another tab in my daughter’s media player. The last two posts were about an English song tab. This one is about English stories.
The Need: The Stories Only Live on the Device
We have a set of eight English picture books about a cartoon pig family. It comes with a small talking reader. The device has eight book icons down the left and right edges. You press a book icon, then press one of ten buttons in the middle, and it reads that page out loud.
Two things I wanted that the device can’t do:
- Play the stories where the device isn’t. Her phone goes everywhere; the device doesn’t.
- Show the words. The device only speaks, in English. I wanted each sentence on screen with a Korean translation under it, so I can follow along with her.
The audio from the set was already on my PC as files, so this was a matter of building the screens.
The Proof: Two Ways to Listen
The new tab has a toggle at the top with two views.
📚 Listen by book. A grid of the eight covers. Tap one and the book opens full screen. All 11 audio tracks of that book play one after another. Below the controls, every line of the book is shown, in English with Korean under it. The line being read is highlighted and the panel scrolls with it. Tap any line to jump there. A checkbox continues into the next book when one ends.
📱 Reader device. A photo of the actual device, cropped to fill the screen. On top of the photo are invisible buttons placed exactly over the real ones. Tap a book icon: it gets a pink outline and reads the book title. Tap a page button: that page plays and the button pulses yellow. Like the real device, it plays only the page you pressed and then stops. Pressing the same button again pauses it. Under the photo is a small caption box with the current sentence and its Korean translation.
Every subtitle and translation is already stored on the NAS. There is no “generate” button anywhere in this tab. That matters because my daughter holds the phone, not me.
When I checked it on the phone that evening, I wrote back one line: “It’s implemented well.” I have not yet watched her use the device view for a long time. If she presses between buttons and gets the wrong page, I will need to make the tap areas bigger.
The Story: One Prompt, and One Thing I Got Slightly Wrong
I wrote the whole request in one message, in Korean. Here it is in English, with the product names replaced:
💬 Prompt that worked “Add a tab for [the pig picture books]. Remove the margins from the images in the thumbnail folder and make thumbnails. This tab has two views. One lets you listen to the 11 audio tracks of each of the 8 books continuously, book by book, with subtitles and translation shown together. The other: the image in the [device] folder is a device children use to listen to this book. Apply the way children operate the device directly on the screen. The four on each side marked ‘book’ are the books. After choosing a book, the top button in the second column from the left is the first episode … the second column from the left goes up to the 6th episode, the top of the second column from the right is the 7th episode, and the bottom of that column is the 11th episode.”
There was a small conflict in my own description. Each book has 11 audio files, but the device has only 10 page buttons, 5 in each middle column. I said the left column held episodes 1 to 6, which is six, not five.
I did not notice this. Claude did. Instead of guessing, it looked at the files. Track 01 of every book was only about 8 seconds long. After transcribing it, track 01 turned out to be just the narrator saying the book’s title. The other ten tracks were the pages.
So the mapping became:
- Book icon → track 01 (the title)
- Left middle column, top to bottom → tracks 02–06
- Right middle column, top to bottom → tracks 07–11
That matches what I said about the right column (7th at the top, 11th at the bottom). Claude explained this in its report and asked me to say so if the real device worked differently. I didn’t need to change anything.
I like that it checked the files rather than forcing my numbers onto the buttons. My description was from memory, and memory was off by one.
The How
The app is a Node.js/Express server and a single HTML file, running in Docker on a Synology DS224+ and served through a Cloudflare Tunnel. The first Kids Player post explains that setup. Below is only what was new for this tab.
1. Cropping the covers and the device photo
The cover images had a plain border around each book. Claude cropped them with Python, Pillow, and numpy:
- Convert to HSV and treat pixels with saturation above 45 as “book.”
- Keep rows and columns where more than 25% of pixels are “book.”
- Run a median filter (size 5) first so small specks don’t extend the crop.
One cover had been scanned upside down. It was rotated 180° before cropping.
The same method failed on the device photo. The device is pink, but the background was also slightly colored, so the crop included background. Claude switched to a mask for the device’s pink color only, and that worked. The result is a 1301×1741 image.
Each cover is saved twice: cover.jpg (long side ≤ 1400px) for the player screen, and thumb.jpg (400px wide) for the grid, so the grid loads fast.
2. A folder per book, no database
Like the song tab, this tab has no database file. The server reads folders:
media/<tab folder>/
mereader.jpg
01 <book title>/
01.m4a ... 11.m4a
cover.jpg
thumb.jpg
.lyrics/01.json ... 11.json
02 <book title>/
...
The original audio file names were inconsistent, so they were renamed to 01.m4a to 11.m4a. The audio itself was not re-encoded.
The server has four small GET routes: list the books, serve one file (with a whitelist regex so only NN.m4a, cover.jpg, and thumb.jpg can be requested), serve the device photo, and return all subtitles for one book in one response. There is no POST route at all, because nothing is generated from the phone.
3. Subtitles and translation, made in advance
The app already had a pipeline for song lyrics. ElevenLabs Scribe produces text with a timestamp for each word, a function groups the words into lines, and Gemini 2.5 Flash translates each line into Korean with short word notes.
For this tab, Claude did not add a new API. It wrote a one-off Node script that cut the two existing functions (buildEngLines and translateLyricLines) out of server.js and ran them on the PC. The output files are in exactly the same format the server already uses.
One detail: the 11 tracks of one book are translated together in one request, not one by one. The story continues across pages, so the translation keeps the same names and tone, and the same word is not explained again on every page.
Two things went wrong during the run:
- ElevenLabs returned 429 “system_busy” partway through. The script had no retry. Claude added retry with backoff and re-ran it. Tracks that were already done were skipped.
- One track came back as a single line, “[outro jingle]”, with no real text. It was the first story page of the last book. Claude moved the bad file aside, transcribed it again, got the real text, and re-translated that book.
At the end, a check confirmed that all 88 tracks had subtitles and every line had a Korean translation: 345 lines in total.
🗂 Claude.md Rule English audio added to the app must have its subtitles and translation generated in the same session and copied to the NAS. “Done” means translation cache 100% filled. My daughter’s phone should never show a “generate” button.
4. Buttons on a photo
The device view is just an <img> with a layer of transparent buttons on top. Their positions are percentages of the image size, so they stay in place at any screen width:
const PP_COL = [13.8, 32.3, 51.1, 69.9]; // left edge of each column, %
const PP_ROW = [18.5, 32.6, 47.0, 61.2, 75.5]; // top edge of each row, %
const PP_W = 15.4, PP_H = 11.4; // button size, %
Columns 1 and 4 are the book icons. Columns 2 and 3 are the page buttons. The image width is limited with min(100%, 440px, calc(60vh * .747)), so the whole device fits on a phone screen with the caption box below it.
The button states are CSS classes: .sel for the chosen book (pink outline), .playing for the page being read (a yellow pulse), and .flash to blink the book icons if a page is pressed before any book is chosen.
5. Subtitle timing and one player at a time
A requestAnimationFrame loop reads the audio’s current time and highlights the matching line. It looks 0.15 seconds ahead, because a line that lights up just after the voice starts feels late.
The app has many players (songs, stories, other tabs). One play listener on the document, in the capture phase, stops whichever other player is running when a new one starts. This tab reuses that. So starting a story stops a song, and the other way around.
6. Cost
- Hosting: $0 (my own NAS and a free Cloudflare Tunnel).
- Transcription: the 88 tracks add up to about 21 minutes of audio, plus a few re-runs after the 429 errors and the one empty track.
- Translation: eight Gemini 2.5 Flash requests, one per book, plus one re-run.
I have not checked the actual bills for this run. At list prices, both together should be well under $1. Claude Code itself is my existing subscription, with no extra charge.
As always, the books and audio are ones we own. This post shows how the tab is built, not where content comes from. If you build something like this, use your own.