Repository navigation
Fix the player's silent hangs and track menus, add tvOS Up Next - #66
Closed
HipsterCat wants to merge 250 commits into
Closed
HipsterCat wants to merge 250 commits into
HipsterCat wants to merge 250 commits into
Conversation
Wire API subtitle tracks into the player with a preference for English non-CC/SDH sidecars, render synced cue overlays, and show an on-pause word panel that translates EN→RU via Apple Translation on supported OS versions. Add tvOS as a supported platform with a TabView root and compile fixes so the client can build for Apple TV. Co-authored-by: Alexander Romanov <traneblow1nd@gmail.com>
- Drop the stale GoogleService-Info.plist and its project references; the Firebase SDK was already removed. - Add a .gitignore and untrack Xcode per-user state that had been committed. - Text-only tab labels on tvOS (SF Symbols read as clutter there) and hide the Downloads tab, which is a non-TV feature. - Make KinoPubButtonStyle and the subtitle word chips react to focus. Both used styles that leave tvOS buttons focusable but visually inert, so nothing showed where the Siri Remote was. - Implement removeFile(at:) in FileSaverMock, which had drifted from FileSaving. - Add CI: tvOS and iOS builds plus the two package test targets that compile. KinoPubKit and KinoPubUI tests are excluded and recorded in the README. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Tests: - Migrate KinoPubUI to SkeletonUI 2.x. The dependency was pinned to `master`, which replaced the builder-chain API with a single call and silently broke the package; pin it to 2.0.2 in both Package.swift and the Xcode project. - Rewrite the KinoPubKit download tests against the current generic API. They register downloads directly instead of via startDownload so the shared background URLSession is only created once. - Fix two wrong assertions in FileSaverTests: "testfile.txt" has a "txt" path extension, and the FileManager mock records attempts rather than successes, so rename its flags to say so. - Replace the KinoPubUI template stub, which called a type that never existed. All four package suites are back in CI. UI: - Move search, sort and filters out of Main into a Search tab with its own navigation stack, on every platform. Main is left as a plain catalog grid until the row-based home lands. - Hide the item-page download button on tvOS. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Home is now stacked rows of artwork instead of a flat grid, led by what the user is part-way through: - Add WatchingItem plus the /v1/watching/movies and /v1/watching/serials endpoints. These return a trimmed item shape, not a full MediaItem, so they get their own model rather than a decode that would fail. - HomeCatalog assembles the rows, fetching the shortcut rows concurrently and restoring declared order afterwards. Rows that come back empty are dropped, and failures are logged per row instead of emptying the screen. - MediaCard/MediaCardView in KinoPubUI describe a card from any payload, with watch progress and a new-episode badge; MediaRowsView stacks the rows and gives cards a focus lift. - Route by id from the watching rows: MediaItemView fetches the full item anyway, so there is no need to fabricate a MediaItem. Also: navigation titles are hidden on tvOS, where the tab bar already says where you are, via platformNavigationTitle. Other platforms keep them. Add the missing .search case to the macOS sidebar, and add macOS to CI — it was the only platform not built there, and it caught this. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Colour: - The accent colour is now white, as on Apple TV, and KinoPubButton's `.green` case is renamed `.accent` — a case named for a colour it no longer is would have misled the next reader. Button labels take a foreground that stays legible on their fill, so white buttons get black text. Ratings: - Add Rating: the average of IMDb and Kinopoisk when both rated a title, otherwise whichever did, and nothing when neither. Zero means "unrated" in the API, so it never drags the average down. - RatingBadgeView draws it top-left of the poster, tiered gold (8.0+, with laurel wings) / green (7.0+) / grey (6.0+) / red. The tier is computed from the rounded, displayed value so a card reading "8.0" can't render green. - Replaces the dual-logo strip along the bottom of the card. Continue watching order: - Wire /v1/history for last-played dates and /v1/watching/serials?subscribed=1 for the watchlist, then order in three buckets: recently played but never added to the watchlist, then the watchlist, then the rest — most recent first within each. The ordering lives in KinoPubBackend, not the view model, so it is covered by tests. - Each fetch is independent; if history or the watchlist fails the row still renders in API order. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Solid colour on the rating badge — the translucency read as muddy. Detail page, shaped like the Apple TV app's: - A full-bleed blurred backdrop that crossfades to the trailer once the player actually reports readyToPlay, so a trailer that never loads simply leaves the artwork in place. - Title, original title, combined rating, metadata line and plot laid over it, with native tvOS buttons underneath: a prominent Watch/Seasons pill, plus Trailer, Watched and Bookmark. - Seasons become a row of tiles carrying per-season watched counts; the full details drop below the fold. - The hero's height is content-driven with a floor rather than fixed: a fixed frame centres anything taller than itself, and clipped() then ate the button row entirely. Fixes found while testing this: - Skeleton cards were live navigation links. Their ids are row indices, not item ids, so clicking one requested /v1/items/0 and got a 404. They are no longer links. - PlayerManager force-unwrapped `URL(string: trailer?.url ?? "")`, which is nil for an item whose trailer carries no link — that crashed on open. Both the media and trailer URLs are optional now, and the Trailer button only appears when there is something to play. - Series showed `duration.total`, the sum of every episode, as a runtime. They show a season count instead. - `.buttonBorderShape(.circle)` is iOS 17+; the deployment target is iOS 16. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- ProgressiveBlur stacks blurred copies masked to start at descending points, so the hero stays sharp at the top and only dissolves behind the text. SwiftUI has no variable-radius blur, and one blurred layer faded in with a gradient leaves a visible seam where it starts. - The scrim now reaches the background colour before the last pixel; landing on it exactly at the bottom left a seam where the hero met the page. The hero also ignores the horizontal safe area so it runs edge to edge on tvOS. - The plot appears once, in the hero, with a More button. ExpandableText decides whether to offer it by asking ViewThatFits whether the full text fits the clamped frame, rather than guessing from a character count. - Detail pages show IMDb and Kinopoisk separately via MediaScoresView; the averaged badge stays on poster cards where there is no room for two. Kinopoisk is now a vector asset rather than a 2x PNG. - The primary button reads Play / Continue / Play Again from watch state, and starts the first unfinished episode of a series instead of opening a list. - Seasons become tabs over a horizontal rail of episode stills with runtimes, watched checkmarks and resume progress, opening on the first unfinished season. Also fixes `MediaItem.isSeries`, which defaulted nil `seasons` to `false` and so reported every film as a series. Removes MediaItemHeaderView and MediaItemDescriptionCard, both now unused. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The checkmark already says it was watched; a full bar underneath it is noise. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
`VariableBlur.metal` samples the layer in a golden-angle spiral whose radius comes from the pixel's vertical position, driven through SwiftUI's layerEffect. That is a genuine variable blur rather than the previous approximation of four fixed-radius layers masked at descending offsets, so the top of the hero stays fully sharp and the falloff has no steps in it. Sampling is accumulated in float because half overflows well before 24 taps, and the spiral spreads taps over the disc's area so a modest count still reads as smooth rather than as rings. The stacked implementation stays as the fallback: layerEffect needs tvOS 17 / macOS 14, and KinoPubUI still supports macOS 13. SwiftPM compiles the shader into KinoPubUI's resource bundle, which is where ShaderLibrary.bundle(.module) looks for it. Xcode 26+ no longer bundles the Metal toolchain, so CI gets a step to fetch it; older Xcodes make that a no-op. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Search and Settings are icons alone on tvOS, the rest are text; other platforms keep icon + label. Profile becomes Settings, which is all it holds, and Bookmarks becomes Saved. Movies and Series are new browse tabs, each a paginated grid pinned to one content type with a sort control. They share one CatalogRoutes type — each tab owns its own path array, so a second identical enum would have bought nothing. TabView's selection is now bound to NavigationState. Without it the app opened on Search, since Search sits leftmost and TabView defaults to the first tab. The grid now draws MediaCardView, the same card the home rows use, so ratings and focus behaviour match everywhere; ContentItemView is deleted. Placeholder cards in the grid are not links, for the same reason as in the rows: their ids are indices, not item ids. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
/v1/watching/* only returns portrait posters, but /v1/history — already fetched for ordering — carries everything a landscape card needs and was being thrown away after the timestamp: `media` has the episode still, season and episode numbers and runtime, and its `item` includes the wide poster the watching endpoints omit. Continue Watching is now a row of wide cards: the episode still for a series or the title's wide artwork for a film, with a play glyph, resume bar and "S1, E7 · 51 min" laid over the image. Score badges are suppressed there — the row is about playback state, not ratings. `isEpisode` requires a season number above zero. Films come back as snumber 0 / number 1, so the first pass labelled every film "S0, E1". Everything decoded from history beyond the item id is optional, so one odd row cannot cost the whole response; a test pins the real payload shape and another pins the id-only minimum. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The roadmap is reorganised around one goal — replacing microiptv for daily use. Plan-minimum first (library sorts and filters, the inert Watched/Bookmark buttons, row pagination, the player bugs), then the rest of kino.pub's data (trailers, awards, collections, every production country), then remembering subtitle and audio choices between episodes, then IMDb tops and recommendations as an exploration. CI: `swift test` compiles KinoPubUI's shader too, so that job needs the Metal toolchain step the build jobs already had — KinoPubUI is the one package failing on CI while passing locally. Both jobs now also print their toolchain versions, since the logs are only readable with admin rights and the iOS build fails on CI while succeeding locally. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Job logs need admin rights to read, annotations do not, so a failing build or test now echoes its error lines back as ::error::. Without this a red CI run reports only "exit code 65" to anyone without repo admin. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The runner is on Xcode 16.4; this machine is on 27, which hid both of these. - KinoPubUI declares its asset catalogue as a resource explicitly. Leaving it to SwiftPM's inference meant `Bundle.module` was not generated on the runner's toolchain, so every `Image(..., bundle: .module)` failed to compile and the whole package test job went red. - SubtitleTranslatePanel had an `@available`-annotated stored property, which is not allowed when the enclosing type is available earlier. That is the same dead wiring the README listed as broken, so rather than paper over it the translation session now lives in its own availability-gated view — where `.translationTask` can finally observe the `@State` that requests it. The feature works now instead of silently doing nothing. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Continue Watching uses wide cover art, not episode stills — the branded landscape image microiptv stretches. `/v1/watching/*` has no wide poster, so Posters.wideURL derives it by swapping the size segment in another poster's URL (the same id is served under /wide/). The detail hero uses it too. - Runtimes format as "1 h 48 min", shared via Duration.hoursMinutes(seconds:). A bare "108 min" read badly for a feature. - Titles are focus-gated on tvOS: poster cards keep the localized title and reveal the original only on focus; Continue Watching shows no title until focused. A Russian audience rarely needs the English line. Driven by a cardFocused environment value the card button styles set, since a label subtree can't read \.isFocused itself. - The landscape footer drops the black fade for a variable blur over just the bottom strip, with vibrant-secondary text that turns white on focus. Roadmap: IMDb narrowed to editorial top lists (no personal recs); dual Russian/English subtitles added to Phase C; the subtitle-default note corrected since that setting already ships. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Skips get two tiers: a subtitle-gap heuristic that needs no external service and can reuse the existing cue parser, and a proper lookup against community segment databases (TheIntroDB by IMDb id, AniSkip/Anime-Skip for anime). Audio fingerprinting — what Plex and Jellyfin's Intro Skipper actually do — is noted as a last resort, since it needs the media files and so only fits downloads. Also: air dates on episode cards, including episodes not out yet. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Both were bound to empty closures. Watched now calls /v1/watching/toggle and reflects state in the glyph. Bookmarks are folders rather than a flag, so the button opens the account's folders and toggles membership per folder via /v1/bookmarks/toggle-item, filling the icon once the item is in at least one. Membership is loaded alongside the item. Both toggles update optimistically and roll back if the request fails. Loading bookmark state failing is logged rather than surfaced — it is secondary to the page and not worth an error over the artwork. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Below the hero the page now carries what microiptv shows and more: - Ratings tiles for kino.pub, IMDb and Kinopoisk with star bars and vote counts, each hidden when that source has nothing. kino.pub's own score is derived from `rating_percentage`, not `rating` — verified against the API, the latter is the net vote count and goes negative (-5 on one item). - Cast and crew as round portraits. kino.pub sends only names, so these are initials; there are no photos and no character names to show, which also sends the parenthesised role away. - Information · Translation · Audio in three columns that stack when the display is too narrow, via ViewThatFits. - Every production country, not just the first. - Series read their tracks from the first episode: only films carry `videos`, so the Translation and Audio columns were empty for every series. Their runtime uses the per-episode average for the same reason `total` read as "6 h". - Language codes go through Foundation, which knows the ISO 639-2 the API sends and localizes them; a 20-language subtitle list is capped with a "+N". The page sits on a heavily blurred, saturated copy of the poster — the colour wash microiptv puts behind item pages. It blurs the small poster, which costs nothing and is indistinguishable once that soft. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Settings uses `gear` rather than `gearshape`. - kino.pub's rating is a thumbs up/down from a handful of users, so it is no longer dressed up as a score out of ten. `rating` is the net count and `rating_votes` the total, which recovers both exactly — 36 over 328 votes is 182 up and 146 down, matching the 55% the API reports. It sits last in the ratings row, after IMDb and Kinopoisk. - A series now shows its total runtime as its own row alongside the per-episode average, rather than one or the other. Roadmap: Kinopoisk API Unofficial as its own phase alongside Phase C. It is what kino.pub itself pulls from, and it has the cast photos and character names our round portraits currently lack — some endpoints work without a key, the fuller ones need one. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…trol The tab bar belongs to the browse surfaces; a pushed detail page keeps it out of the way and relies on the remote's Back button on tvOS, the navigation bar elsewhere. The synopsis is now a single focusable control clamped to three lines with an inline More, secondary at rest and backed once focused. Selecting it opens the full text over the page instead of expanding in place and shoving the artwork around. Truncation is detected by asking whether the full copy fits the clamped frame, so More only appears when there is something behind it. ExpandableText was left with no callers and is deleted. README records kinoapi.com as the reference along with the three places its 2020-era description does not match what the service returns, and the decoding tests that pin the real shapes. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The Search tab is now the library. With no query it lists the catalog through `/v1/items` with a sort and filters; typing switches it to search. Sorting and filtering are Menus rather than sheets — one click to open, one to choose. All seven sorts, recently added by default. `kinopoisk_rating` and `imdb_rating` are absent from the published docs, so before offering them I checked live responses: both are accepted and correctly ordered. Filters are type, genre, country and release decade — decades rather than a free from/to range, because a two-ended numeric picker is miserable to drive with a remote. The API takes any range if that should change. Two bugs found on the way, both of which would have made this quietly useless: - `GenresRequest` pointed at `/v1/countries`, so the genre picker would have filled with country names. - `MediaGenre.id` was declared `String` while the API sends an integer, so every genre response failed to decode. Presumably why the old filter sheet was never wired up to anything. Changing content type clears the chosen genre, since genres are per type. Picker data failing is non-fatal — the listing still works without it. Removes the old FilterView, FilterModel and FilterDataService, none of which had callers once Search stopped presenting the sheet. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The takeover was written but never ran: the hero started it from `.task`, which fires on appear, and at that point `mediaItem` is still the mock the model starts with — no trailer URL, and nothing looked again once the details arrived. Keyed on the URL instead, so it starts when there is something to start. The rest is what that exposed once it did run: - `VideoPlayer` is an `AVPlayerViewController` — transport chrome, its own focus behaviour, and the video fitted inside the frame with bars either side. Replaced with a bare `AVPlayerLayer` at `resizeAspectFill`. - Status was observed through `\.currentItem?.status`, a key path through an optional that doesn't reliably deliver. Observed on the item. - The artwork holds the frame for two seconds first, the way the Apple TV app does it, so scrolling past a page doesn't spin up a video. - An extra scrim over the trailer only: the artwork arrives softened by the progressive blur, a sharp bright moving frame does not, and the title was unreadable over a cartoon sky. Verified on the tvOS simulator against «Обсессия» and «История игрушек 5» — plays full-bleed, text stays legible. The black borders visible on some trailers are baked into the source file, noted in the README. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Saved made you open a folder before you could see anything in it. It is now built the same way Home is — one horizontal shelf of artwork per bookmark folder, its item count beside the title in secondary type, and the title itself leading to the folder's full screen. Folders are ordered by when something was last added to them, not by when the folder was created: a list filled last week outranks one last touched two months ago. `folder.updated` is the only signal for this — items carry no per-membership date, and the field tracks contents rather than views, verified against live responses. Empty folders are dropped rather than drawn as a bare title over empty space. Row headers had to span the full row width to be any use on TV: the focus engine only moves up into what sits directly above the focused card, so a title-width button was unreachable from every card but the first. The chevron appears on focus only, the way the Apple TV app reveals "See All". Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
The roadmap said "Russian on top, English below", which decides for the user what they are watching in. It is an option now, off by default, and which two tracks it shows is the user's call: a captions button in the player lists every track the item offers and takes a first and a second pick, in any language. They render as one block at the bottom of the frame, the second line directly under the first and slightly smaller — not split top and bottom, where eyes that have to cross the screen miss both. Each track keeps its own timings and is looked up independently, since two subtitle files rarely break lines in the same places. The same picker closes the other half of Phase C. Sidecar URLs differ from episode to episode, so remembering the URL would pre-select nothing on the next one; the choice is stored as language + CC-ness + position within that language (`SubtitleTrackReference`) and re-resolved against each episode's own tracks, falling back within the language before falling back to the defaults. "Off" is remembered as a choice too, otherwise the default preference turns subtitles back on every episode. Profile → Playback now only seeds the default second language for items nothing was ever picked on. Track heuristics moved to `KinoPubBackend/Models/SubtitleTracks.swift` so they could be tested, which closes the CC detection issue on the way: - markers are matched as whole words rather than substrings, so `hindi.srt` is no longer a hearing-impaired track and `enforced.srt` no longer a forced one - `forced` is its own flag rather than a CC marker, and never a default — those tracks carry signage only and leave most of a film silent - three-letter, two-letter and regioned codes collapse to one key, so `eng`, `en` and `en-GB` are the same language when matching a remembered pick Also: player chrome is disabled while it is hidden, so the back and captions buttons stop taking focus during playback. Verified by building tvOS, iOS and macOS and by the package suites (88 backend tests, 13 of them new). Not driven with a Siri Remote against a real stream — the player is behind authorization — so the picker's focus behaviour is recorded in Known issues. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Every part of this cost time in the last change, and none of it is guessable: - `-destination 'name=Apple TV'` matches nothing when the only TV runtime is a 4K one, which is the common case. The doc told agents to run a command that errors. - Screenshots, install and launch are headless via `simctl` — no window, no Simulator.app, no asking the user to look. - `print()` never reaches the unified log and a backgrounded `log stream` dies with its shell, so debugging output silently vanishes. `Logger` + `log show` works. - There is no Simulator.app on current Xcode. The window belongs to Device Hub, and agents that request "Simulator" conclude the device cannot be driven at all. - Escape is not the Menu button. Also the general point the trailer bug made: it built cleanly on three platforms and never ran. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The tab opened onto bookmark folders, which are mostly films someone gave up
on half-way. What actually needs looking at is the serials being followed for
new episodes, so /v1/watching/serials?subscribed=1 now leads the screen.
Order comes from the API untouched. It carries no dates at all — only total,
watched and new — but it arrives sorted by the item's updated_at: checked
against /v1/items/{id} for all 14 entries of a live watchlist, the order
matched updated_at descending exactly, 2026-07 down to 2018-09. Sorting by
"new" instead, which was the obvious-looking move, buries the show that got
an episode yesterday under a finished one with 171 unwatched.
The row is published the moment it lands rather than waiting on the folder
requests, and it is dropped when the watchlist is empty or the request fails —
the folders below are still worth showing.
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Skeleton placeholders are gone everywhere (SkeletonUI dropped as a dependency): lists start empty and show a single delayed spinner (LoadingIndicatorView) instead of stand-in cards, then fade the real content in. The item page waits for its details the same way, with the artwork wash showing immediately when the caller already has the MediaItem to seed it with. Home and Saved pick up a Netflix/Apple-TV-style layout on top of that: the focused card's wide artwork fills the screen with its title, meta and plot in a reserved preview zone, cards drop their captions (shown only for grids without a preview, on focus), and colours move to the system palette instead of a hand-picked grey. Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Both were superseded (MediaItemInfoColumns, MediaScoresView/RatingBadgeView) and only referenced by their own previews.
Three turns of talking about this and no endpoint. Now there is one.
GET /v1/title/by/kinopub/{id}?imdb=…&kinopoisk=…&year=…&original=…&type=…
GET /img/{kind}/{size}/kinopub/{id}?imdb=…
The hints are the mechanism. The client already holds the IMDb id, the
Kinopoisk id, the original title and the year, because kino.pub sends them on
every item - so a title the record has never seen is still resolvable on the
spot and the API never has to answer "I do not know that one".
The response is always immediate. Warm returns the stored document; cold
returns one built from the hints alone, marked pending, with kino.pub artwork
that is addressable from the id. Real resolution runs after the response in
ctx.waitUntil, so whoever opens a cold title first pays nothing and warms it for
everyone behind them. The image route cannot return a hole: with nothing better
known it redirects to kino.pub's own poster, which needs only the id.
Documents are stored as JSON rather than re-assembled per request, which was the
right call for a reason beyond speed: the stored document is the log. When a
field looks wrong the exact bytes the client received sit on disk beside the raw
provider payloads that produced them, instead of having to reconstruct what the
answer would have been under an older schema. The schema stays a draft and free
to churn; the document is the contract. 300 titles export to 1.5 MB, mean 4.7 KB.
Season is an entity now, not a number on an episode. kino.pub addresses seasons
as {id}/s01 and TMDB was already sending a poster, an overview, an air date and
an episode count for each one - all of which we were discarding because there
was nowhere to put it. Episodes gained runtime and a still for the same reason.
The nightly identity job is gone. Pre-resolving 45 000 titles nobody has opened
was the sweep habit wearing a schedule, and identity is exactly the thing that
should be lazy: the API resolves it when a client asks. resolve_identity.py
survives as a hand tool for warming a list you actually care about.
Backups now decide for themselves - skipped unless the schema version moved or
the identity map grew more than 2%, since otherwise a 640 KB blob accumulates
nightly for a map that barely moves.
KV is declared but commented out in wrangler.toml; the worker degrades to
hints-only documents and kino.pub artwork until the namespace exists.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
56 posters, 70 backdrops, 9 logos per title was never the plan - it was every row the record happened to hold, unfiltered. Cut to at most three per kind: ru, en, and textless (lang NULL - the one meant to sit under a locally-drawn logo overlay). Any other language is dropped outright rather than folded into "textless", since a French poster does carry text, just not one that was asked for. Same logic in both places that build a document - document.cap_artwork() for the offline pipeline, capArtwork() in the worker for the live merge - each picking the first match per language from a list already ordered by source trust and width, so the best available candidate wins the slot rather than an arbitrary one. Verified end to end on a title the worker had never resolved before: 12.5 KB document, three posters, three backdrops, two logos (TMDB has no textless logo variant, which is correct, not a bug). A point request for a specific replacement image remains available whenever one is actually wanted - this only removes the gallery nobody asked to browse. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Wired the three-tier Kinopoisk strategy the user actually asked for, and found three real bugs doing it. Tier 1, the keyless kpapp.link proxy (facts/reviews/staff-with-characters/ stills, no key, no documented quota) is now wired into both the offline pipeline (fetch_kinopoisk_proxy.py, lazy, per-title) and the live worker (facts only, for now). It was documented three commits ago and never implemented. facts/review tables existed or were added; both were empty before this. The bulk keyed backfill (bulk_films.py against the whole catalogue) is explicitly deprioritized in the policy - lazy tiers 1+2 already cover most of what a page needs, and a full sweep burns a personal key's quota on titles nobody opened, the exact habit rejected everywhere else in this repo. Cast photo precedence now follows the user's stated rule: pushbr (kino.pub's own CDN, md5(name), free, no fetch) leads by default since the catalogue is mostly CIS-facing; TMDB leads instead specifically when Kinopoisk's own vote count for a title is under ~10% of IMDb/TMDB's, or the title has no CIS country tag and isn't a big enough global hit (~100k+ IMDb votes) for Kinopoisk to cover it regardless of origin. Both candidates travel in the document as an ordered photos[] list - the client tries each in turn, same as ActorImageProvider.swift already does on a pushbr 403, except the server picks the order now instead of every client guessing it the same way. Three bugs surfaced by building this rather than by reading it: - fetch_tmdb.py's derive() labeled every credit name `name_en` regardless of the fetch language. Since the default fetch is ru-RU, TMDB's *Russian* localized name was being stored as English - so a Russian name Kinopoisk wrote correctly under name_ru could never join against the identical Russian name TMDB wrote under name_en. Same person, same script, split into two rows by a mislabeled column - not the cross-alphabet gap the policy already named, a plain bug sitting in front of it. TMDB conveniently returns `original_name` on the same credit row, so the fix costs nothing: `name` under the fetch's actual language, `original_name` for the other slot when it differs. Same mislabeling existed in the worker's mergeTMDB and got the same fix, plus a from-scratch MD5 implementation (Web Crypto has no MD5) so pushbr URLs can be computed server-side there too - checked against the RFC 1321 test vectors and against Python's hashlib byte for byte before deploying. - --rederive never replayed kinopoisk_proxy's raw payloads - the first --rederive after wiring the new source silently dropped every fact and review it had just fetched. Added replay_kinopoisk_proxy alongside the existing tmdb/omdb replay functions; verified round-trip with zero network requests. - The worker's facts fetch was gated on `!base.ids?.kinopoisk`, but draftDocument already sets that id from the client's own hint before any fetch runs - so the branch could never fire. Caught by testing against a title kpapp.link confirmed has two real facts and getting zero back. Replaced with an explicit `factsChecked` flag, set once per attempt and distinguished from "not yet tried" so a transient failure doesn't get cached as "no facts" forever. Verified end to end, live: a fresh title (The Aftermath, never resolved by the worker before) came back cold with pending:true, warmed in 3 seconds with ru-RU names actually labeled ru-RU, pushbr as the lead photo candidate for a mostly-Western cast (correct - no CIS tag, but Keira Knightley/Skarsgård-level recognition clears the big-hit threshold... worth rechecking that threshold value against real distribution later), and both real facts present after the factsChecked fix. Shawshank's 108 duplicate-laden credit rows became 111 distinct people with zero name collisions after the language fix plus a new build_credits() that merges rows sharing a person_id (which the language fix made actually meet) rather than listing one row per contributing source. 23 new tests (81 total): pushbr's hash against a hand-computed MD5, the thin-coverage heuristic's four branches, photo-candidate ordering and deduplication, credit merging across sources and departments, and the language-labeling bug itself - both the unit-level mislabeling and an end-to-end check that two sources' identical Russian spelling now resolve to one person row. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…gress chrome. Port the community per-item optimistic library without their UI. Continue Watching paints from local resume without resetting Home TTL, and cards/rails classify watch state only through WatchProgress.resumeFraction. Co-authored-by: Alexander Romanov <traneblow1nd@gmail.com>
Four things were wrong in the same area, and one of them had been wrong for a while. A poster's height stopped following its column. MediaCardView measured the artwork's own width and derived the height from it, then fed that height back through aspectRatio(_:contentMode: .fit) — which accepts *every* width no wider than the one offered, so `w x w/ratio` is a stable answer for any w. The card kept whatever width it first landed on: widen the window and the column and the caption grew while the artwork stood still, which is the gap that opened between rows and the caption that ended up wider than the poster over it. Measured with three columns at 500pt then 1100pt: grid height 194 -> 98 before, 193 -> 425 after. The width now comes from a zero-height maxWidth-infinity probe above the artwork, which nothing can clamp, and the box is pinned in both directions. ShelfMetrics stopped falling back at the top of the ladder. Past the last named breakpoint it went from 8 columns to 6, so every card inflated the moment a window passed 1600pt; it now adds a column every 200pt and the card stays near 170-190pt at any width. The gutter is derived as inset/2 instead of hand-picked per step (20/28/32, and 24 for banners) — one number decides the rhythm, and the gap between two posters is half the page margin because neighbours share it. The phone step runs to 520pt so a 440pt Pro Max keeps three columns instead of four 72pt ones. Section spacing is two numbers now, sectionHeaderSpacing and rowSpacing, and landscapeFocusPadding equals focusPadding off tvOS — it is focus-lift room and there is no focus engine there, so Continue Watching had been sitting 4pt further from its header, and from the section below, than every poster rail. Search on macOS never reached /v1/items/search. The field is in the window toolbar and holds the text before SearchView exists; the view reads it through onChange, which only fires for changes made after it is on screen. The first load ran with an empty query, took the /v1/items branch, and answered with the unfiltered catalogue — for Return too, not just for typing. Only the next keystroke got through. LibraryCatalog.init takes the query now (assigned before subscribe(), so no duplicate refresh rides behind the first load) and the first non-blank character opens the surface instead of Return. The whole sequence is documented on LibraryCatalog itself rather than here, at the user's request: it spans four files and the wake-up order is what matters. Smaller, same pass: - SectionHeader hugs its own words. It was maxWidth-infinity with the tap shape over all of it, so the "see all" link covered the full width of the grid — a click level with the title but four columns right navigated. - MediaRow.onOpen: a "see all" that is not a push. Continue Watching's chevron selects the Library tab, where the same titles live split into series, films and history. HomeCatalog still knows nothing about tabs. - The macOS tab bar stays drawn while searching; search fills the content of the tab it was entered from, and that one tab only, since every root stays mounted and answering "search" for all four would stand up four SearchViews. - Empty states are centred instead of hugging the left edge of the window. An HDR-boosted hover on section headers was tried and reverted the same day — over a grey page with white labels the boost is invisible, and the region it lit was the full-width hit area above. Do not re-add it unasked. Verified: macOS build green, and the card geometry measured by hosting the real MediaCardView at two widths. Nothing here has been watched running — the search path in particular is worth one manual check. This also carries in-flight type and chrome tuning that was already in the working tree (TypeScale weights, MediaActionButtonStyle, LibraryFiltersBar, the hero and the UILab toolbar); it is not mine and is unreviewed here. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three separate things put the wrong titles in the row, and the row itself was unbounded. Order. Neither /v1/watching/movies nor /v1/watching/serials carries a timestamp - the only dates in this row come from the single page of /v1/history we fetch. So most titles are undated, tied on bucket and on date, and the tiebreak fell through to whatever order the server listed them in. That is why a cartoon last touched years ago sat above a subscribed show with one new episode waiting: both on the watchlist, both with new episodes, both undated. The tie now goes to the smaller backlog - `new`, the count of unwatched episodes. One or two waiting is a title being followed; a hundred and seventy is one abandoned. Films have no counters and count as zero, which is deliberate: a half-watched film is one press from finished, so it belongs above a serial with a season to go. A real play still outranks all of it. Junk. WatchProgress.startedSeconds is 10s and stays there - it answers "has this begun", which is what paints a progress bar on a card already in the row. Putting a *new* card there is a bigger claim, and a local-only one has nothing to take it back out again: the server does not list the title, so no refresh drops it. That is how a minute of a trailer became something to "continue". New floor, enterContinueWatchingSeconds at 90s, gates local-only inserts in both places that make them - the fetch-time pool and the read-time overlay, which already had `canInsert` for exactly this and was passing true. Length. /v1/watching/serials lists every unfinished serial on the account, which for a long-lived one is hundreds. The shelf scrolled sideways forever and, past the first screen, was a list of things nobody remembers starting. Capped at ContinueWatchingOrder.maxItems; the whole list lives in the Library tab, which is where the row's chevron goes as of the last commit. The rules are written down in docs/product/continue-watching.md rather than here or in AGENTS.md - they are what the product does, per the docs policy. The open part is stated there too: backlog is a proxy for "am I following this", and getting real dates for more of the row means more of /v1/history (perpage maxes at 50, and one page of 20 already measured ~96 KB cold), so it is a payload trade rather than a free win. Found on the way and fixed: ContinueWatchingLocalOverlayTests had two calls with their arguments in the wrong order, which stopped the entire KinoPubBackend test target from compiling - so none of these tests had been runnable. 305 pass now, six of them new and each checked to fail without the change. Not watched running. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
) * Decide which dub a title opens with, as a rule instead of guesswork kino.pub ships several dubs per episode and they arrive over time: an episode may have one two-voice track on Monday and a studio dub on Thursday. Nothing in the app had an opinion about that. `AudioTrackMemory` stored one rendition name per title, which does not survive an episode change, and there was no written expectation to test against. `TrackResolver` is one pure function over a menu, what the scopes remember, the settings and the system languages — no player, no network, no storage, so a card can ask it before the player exists. - Scopes, most specific first: season, title, the `anime` class, then the ladder. Season because studios change between seasons; the anime class because a preference for watching anime in the original belongs to anime and not to one title. Cartoons are not anime. - A dub is remembered as language + kind + studio. A rendition name, index or URL differs between two episodes of one season, so remembering one pre-selects nothing on the next. - Weight is episodes actually watched, not picker opens: two episodes of a stopgap must not outrank a season of the real preference. Count orders the ladder, recency only breaks ties. - A dub never offered before beats a habit still under `confidentWeight`. That is the "watched three episodes before anyone dubbed it properly, came back to five more seasons" case. Above it the habit holds, so a fresh dub cannot displace a studio chosen for a season. - `minimumDubKind` prefers the original with subtitles over a dub below the floor, and never filters original tracks. Ships as nil so nothing changes silently. - Subtitles come on when the audio that won is in a language the viewer does not read — one rule for anime in Japanese and for the film that only had English. Nothing is wired: the player still uses AudioTrackMemory and AudioTrackRanker. The 50 scenario tests are not compiled or run — no Swift toolchain in this session. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TiVZ1LwWvyKeMggAjJ4nHK * Ask the episode itself before the season, and put the chain in one place The priority chain is episode -> season -> title -> genre -> app settings -> system languages, and the episode level was missing: a rewatch should replay what that episode actually played, not re-derive it. `TrackMemoryScope.chain` is now the single place that order is written down, so a caller cannot assemble it differently. App settings mirror the system language list until they are set; neither is a scope, both are the ladder the resolver falls back to. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TiVZ1LwWvyKeMggAjJ4nHK * Write the full priority chain into the rules doc Episode, season, title, genre, app settings, system — and that the last two are the ladder rather than scopes, since app settings mirror the system list until they are set. Drops the rewatch open question: the episode scope answers it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TiVZ1LwWvyKeMggAjJ4nHK * Play the dub the rules choose, and learn from what was watched Wires TrackResolver into the player and retires the two mechanisms it replaces. `AudioTrackMemory` keyed a dub by its rendition display name. HLS uniques duplicate NAMEs with a " ∙ n" suffix and renditions are not ordered the same way across episodes, so the handle did not survive the jump to the next episode — which is the one moment it existed for. `AudioTrackRanker` ranked options with a copy of the detail page's ladder and knew nothing about history. - `TrackPreferenceStore` owns the ledgers, keyed by scope, and writes to every scope a play teaches. It migrates `subtitleTrackChoices` (a real language code, so it converts) and drops `audioTrackChoices` (a display name, so it cannot). - `PlaybackSession` derives `TitleTrackProfile` and hands it to the player: the item payload knows its genres and countries, an `Episode` does not. - Audio and subtitles ask the same resolver, so they cannot disagree about what is playing. Without API track metadata the menu is synthesised from the renditions, so an unlabelled master still gets a considered pick. - A play is recorded once it passes `enterContinueWatchingSeconds`, not on open: weight is episodes watched, and a title sampled for a minute teaches nothing. Behaviour change worth naming: subtitles no longer come on merely because an English track exists. They come on when the audio that won is in a language the viewer does not read. The old "Default English subtitles" switch now decides which language wins when they do, rather than forcing them on. Also fences `TVUIKitMediaItemStatusTests` to tvOS — `TVUIKitMediaItem` is behind `#if os(tvOS)` and the class broke `swift test` for all of KinoPubUI on the macOS runner. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TiVZ1LwWvyKeMggAjJ4nHK * Record the player wiring in ROADMAP and the changelog Including what is not verified: it builds and the rules are tested, but no one has watched the right rendition get selected on a device. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TiVZ1LwWvyKeMggAjJ4nHK * Stop a macOS-only hover state from breaking the tvOS and iOS builds `isHovered` is declared under `#if os(macOS)` — there is no pointer anywhere else — but the watched-artwork opacity read it unfenced, so both simulator builds failed with "cannot find 'isHovered' in scope". Pre-existing, from 91e46da; the package's own `swift test` runs on macOS and never saw it. The dim is now one computed property that answers without hover off macOS. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TiVZ1LwWvyKeMggAjJ4nHK * Point the player skill at the resolver, and record the swift-test blind spot The skill still said the resolver was unwired, and nothing recorded that a package green under `swift test` can still break every simulator build: that job runs macOS only, so a macOS-fenced symbol used unfenced passes there. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TiVZ1LwWvyKeMggAjJ4nHK * Give subtitles the same standing as audio, and stop classifying genres twice Two corrections from the owner. **Anime is decided in one place.** `TitleTrackProfile` had grown its own genre matcher, which is exactly the defect AGENTS.md names: a rule that depends on type or genre belongs to `MediaPresentationProfile`, or the same word ends up meaning two things on two surfaces. That profile now answers `isAnime`, kept separate from `kind` because an anime and a cartoon are *drawn* the same way and only one of them is normally watched in the original. kino.pub files anime as a genre — `MediaType` has no case for it — so it matches the genre title, and a cartoon is deliberately not anime. **Subtitles are the same mechanism as audio, not a lesser one.** The previous pass made them appear only when the audio was unintelligible, which quietly demoted a localization preference into an accessibility fallback. With no history the app now mirrors the system — Accessibility → Subtitles & Captioning via `MediaAccessibility`, whose caption languages also seed the language order — and the foreign-audio rule sits on top of that rather than replacing it. Subtitles in a language the viewer cannot read still beat nothing once subtitles were asked for, and an item that offered no track writes nothing at all. Also removes a stray `UILabGlassChipStyle` modifier that had been pasted into `LibraryFiltersBar`: the style is macOS- and DEBUG-only, so it broke the tvOS and iOS builds. Pre-existing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TiVZ1LwWvyKeMggAjJ4nHK * Take the caption languages rather than binding them `MACaptionAppearanceCopySelectedLanguages` returns a non-optional `Unmanaged<CFArray>`, so `if let` on it does not compile: "initializer for conditional binding must have Optional type". Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TiVZ1LwWvyKeMggAjJ4nHK * Make the rendition bridge testable without an asset The step between "the resolver chose this dub" and "the player selected that rendition" was the least verified thing in the feature and the one with the sharpest edges: HLS forbids two identical NAME= in a group, so duplicates carry a " ∙ n" suffix, one label can be a prefix of another, and a master we did not relabel has no API row to map back to at all. It lived in `PlayerManager` behind `AVMediaSelectionOption`, which needs a real asset to construct — so it could not be tested. `AudioRenditions` now owns the rules over an `AudioRendition` protocol that `AVMediaSelectionOption` conforms to, and 13 cases cover them with a fake conformer: exact match, suffixed duplicate, two dubs of one language, a label that is a prefix of a longer one, the synthesised-menu index path, and reading a signature off a name when there is no API row. One behaviour fix falls out: the signature lookup matched any API row whose label was a prefix of the rendition name, so "Русский ∙ Дубляж" could claim "Русский ∙ Дубляж, LostFilm". It now requires the duplicate suffix. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TiVZ1LwWvyKeMggAjJ4nHK * Reconcile the changelog with the design that actually shipped The first pass's subtitle bullet described behaviour corrected the same day; leaving both versions is how a voided rule comes back. Also records the three pre-existing CI breaks and the reason none of them were visible: `swift test` runs a package on macOS only, so green package tests say nothing about tvOS or iOS fencing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TiVZ1LwWvyKeMggAjJ4nHK * Add app test targets, and a CI job that actually runs them There was no test target above the package boundary, so nothing in the app was testable — the store, the player wiring and every view were all unreachable from a test. - `KinoPubAppleClientTests` (unit, hosted by the app) covers `TrackPreferenceStore`: which scopes a play teaches, that weight accumulates across a series while each episode sees one play, that ledgers survive a relaunch, and the one-way migration off `subtitleTrackChoices` — including that "subtitles off" survives it and that it never overwrites a ledger that already exists. - `KinoPubAppleClientUITests` (XCUITest) covers what a runner with no kino.pub session honestly can: the app launches, and it paints something. Both have broken before, and a black launch screen is the worst tvOS regression there is. Focus is deliberately not asserted — it needs content, and a screenshot cannot show whether a landing felt right. - Both bundles are `buildForTesting` only in the scheme, so the existing compile jobs are exactly as fast as they were. - A new `test-app` job runs them on a tvOS and an iOS simulator, picked by udid rather than by name: runner images rename their devices, and a hard-coded "Apple TV 4K (3rd generation)" is a job that dies on the next image refresh. The compile jobs stay on every change. They are what caught three pre-existing breaks this week, and narrowing them would have hidden all three. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TiVZ1LwWvyKeMggAjJ4nHK * Import the app module by its real name `PRODUCT_NAME` is KinoPub and nothing overrides `PRODUCT_MODULE_NAME`, so the app's Swift module is `KinoPub`, not the target name. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TiVZ1LwWvyKeMggAjJ4nHK * Make the AudioRendition witnesses public Conforming an imported type to a public protocol from another module requires the witnesses to be public, whatever the visibility of the conformance itself. I had "aligned" them down to internal, which broke the tvOS build: property 'renditionName' must be declared public because it matches a requirement in public protocol 'AudioRendition' Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TiVZ1LwWvyKeMggAjJ4nHK * Record the two traps the test targets cost The app's module is KinoPub, not the target name, and a witness to a public protocol from another module must be public even when the conformance is internal. Both cost a CI round; neither is guessable from the surrounding code. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TiVZ1LwWvyKeMggAjJ4nHK * Let a surface ask what a title will play, before the player exists The player owned the answer to "which dub does this open with", so nothing else could ask it — and the one request playing a series episode needs was made after the player was already on screen, which is dead time spent on a spinner. `PlaybackPreflight` takes both jobs: - `decision(for:profile:)` answers from what is already loaded, with no network, so a card or a Play button can ask while drawing. `PlayerManager` now asks the same way and only contributes the one thing it alone can see — the real renditions, for a master the API gave no track metadata for. Two ways of assembling those inputs would have been two answers. - `warm(_:)` fetches an episode's links ahead of the tap. The detail hero warms its play target while the page is on screen, which is where that request belongs. It is a no-op for anything already playable, and `MediaLinksResolver` runs one request per media id however many callers ask, so re-running it is free. - `audioSummary(for:profile:)` names the dub that will play, for a label. Nil for a single track: naming the only option tells the viewer nothing. Warming is deliberately silent on failure. It is an optimisation; the player still resolves for itself and still owns reporting a stream it cannot play. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TiVZ1LwWvyKeMggAjJ4nHK * Record the preflight and the test targets Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TiVZ1LwWvyKeMggAjJ4nHK * Make the answer an object surfaces hand each other The preflight answered questions, but every asker still made its own call — so "which dub will this play" was a derivation repeated at each surface rather than a thing that travels with the title. `PlaybackPlan` is that thing. - It carries the decision, the scopes it was read from, and whether it is provisional. It knows how to say itself: `audioLabel` names the dub, and is nil when there was no choice to make, because naming the only option tells the viewer nothing. `isRemembered` separates "this is what you watch" from "this is what we picked" — what a diagnostics screen groups by. - `PlaybackPreflight` caches one plan per item, keyed on the store's `revision` and on the menu it was decided from. Two surfaces asking get the identical answer, and the moment the viewer's history changes the cached one is rebuilt. A time-based cache would have gone stale exactly when someone switched dub and expected it to stick. - `PlaybackSession` makes the plan and hands it to `PlayerManager`, which publishes it and refreshes it once the asset shows its real renditions — at which point it stops being provisional. A plan made from API metadata and one made from real renditions never share a cache slot. Also wires `PlaybackPreflightTests` into the project; it was written but never added to the target, so it had not been compiling. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TiVZ1LwWvyKeMggAjJ4nHK * Let the player ask the preflight from where it actually asks `PlaybackPreflight` was `@MainActor`, but `PlayerManager` is not isolated and asks synchronously — from its periodic time observer among other places — so every call site was an error: call to main actor-isolated instance method 'scopes(for:profile:)' in a synchronous nonisolated context Isolation now sits only where it is needed: `warm` stays on the main actor because `MediaLinksResolver` is, and answering what a title will play does not. Both caches are behind an `NSLock` instead, which is what that shape needed anyway — the player records a play off the main thread while a card reads a plan on it. `TrackPreferenceStore` gets the same treatment, matching `LocalWatchProgressStore`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TiVZ1LwWvyKeMggAjJ4nHK * Show what the app remembers about tracks, to everyone A preference that steers playback without ever being visible is one the viewer cannot correct. This is not a debug screen: it says plainly what was learned, where it applies and how strongly, in the same grouping the resolver reads in. - `TrackPreferenceDigest` does the arranging — sections per title, broadest scope first because that is the order a reader thinks in, titles heaviest first, class buckets last because they are not about any one title. A scope that learned nothing is left out: a row saying a series has no preference tells the reader nothing, and seeing a menu is not learning a preference. - `TrackMemoryScope` parses back from its storage key, which is why that key stays a format and not a hash. A key this build does not understand is skipped rather than guessed at. - A signature says itself — `Русский ∙ Дубляж, LostFilm` — built from the signature alone, because the API row it came from belongs to an episode that may be long gone. - The weight is shown. It is what decides ties, so it is the honest answer to "why that one". Two renderers, one digest: `Form`/`Section`/`LabeledContent` off tvOS, matching `StorageSettingsView`, and plain text inside the existing `SettingsSection` on tvOS — nothing there is actionable, and a focusable row that does nothing is a trap on a remote. The Playback pane also gains the ladder itself: the dub floor, anime in the original, and read-only rows for the preferred audio languages and whether the system wants captions, so the switches and the summary cannot disagree. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TiVZ1LwWvyKeMggAjJ4nHK * Drop LT from the keys added this session LT is not shipping in the first version, and won't be added piecemeal per key — that is a batch pass the owner runs once, deliberately, across every string at once, with their own policy on wording and title case. Only touches the keys this session added; the rest of the catalog's LT entries are pre-existing and out of scope here. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TiVZ1LwWvyKeMggAjJ4nHK * Stop lowercasing a studio name that displayLabel then shows AudioTrackSignature lowercased `studio` at construction so matching would be case-insensitive — but displayLabel read that same lowercased field, so "Мосфильм" showed up as "мосфильм". CI caught it: XCTAssertEqual failed: ("Russian ∙ Dubbed, мосфильм") is not equal to ("Russian ∙ Dubbed, Мосфильм") `studio` now keeps its original case; equality and hashing compare a private `studioKey` (lowercased) instead, so two spellings of one studio are still one signature — matching behaviour is unchanged, only what gets displayed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TiVZ1LwWvyKeMggAjJ4nHK * Fix a string-symbol collision between two keys that differed only by "?" CI: "This string key would generate the same symbol as 'Forget remembered tracks?'" — Xcode's generated string symbols strip punctuation, so "Forget remembered tracks" and "Forget remembered tracks?" mapped to the same Swift symbol and the whole app target failed to build. The alert now reuses the button's key instead of a near-duplicate with a question mark, which is also one fewer key in the catalog. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TiVZ1LwWvyKeMggAjJ4nHK * Remove Lithuanian from the localization catalogs entirely Not shipping in the first version, and won't be added piecemeal — that's a batch pass across every string at once, done deliberately, later. Strips "lt" from every key in both catalogs (232 across the two files), not only the ones touched this session, so nobody spends time translating into a language that isn't going out. Also drops "lt" from the in-app language picker (ProfileModel): with no translations behind it, selecting Lithuanian there would have silently fallen back to English everywhere, which is worse than not offering it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TiVZ1LwWvyKeMggAjJ4nHK * Fix wrong type for systemWantsCaptions in PlaybackSettingsPane 'systemWantsCaptions' is declared in the PlaybackLanguagePreferences extension, not on SubtitlePreferences — a copy-paste slip from the neighbouring SubtitlePreferences.secondLanguageOptions line. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TiVZ1LwWvyKeMggAjJ4nHK --------- Co-authored-by: Claude <noreply@anthropic.com>
Archive the one multiplatform target for iOS, tvOS and macOS and push to TestFlight as a technical internal build. Manual dispatch only; signing is an App Store Connect API key plus a persistent Distribution p12, matching how Swiftfin does it without a Match certs repo.
Do not pass CODE_SIGN_IDENTITY or the profile specifier as global xcargs: xcodebuild parsed the macosx* override as the identity name, and SPM packages rejected the App Store profile. Sign only the app target. Accept the App Store Connect key as PEM or base64.
A Fastlane or changelog-only commit does not need the tvOS/iOS UI-test matrix. paths-ignore on the compile workflow; mixed PRs still run.
xcassets and Localizable.xcstrings still compile. Simulator UI tests only run when Swift, plists, Metal, or the project file change.
Skip app CI when the diff cannot compile
Xcodeproj opened KinoPubAppleClient.xcodeproj relative to fastlane/, so apply_app_signing never saw the app target. Use an absolute repo-root path.
…17) Signing archived, then iOS 90717 (alpha on the 1024 icon), tvOS 90513 (missing CFBundlePrimaryIcon / Top Shelf Wide), and macOS Release failed on DEBUG-only UILabGlassChipStyle.
gym treats a multiplatform archive as IPA unless sdk is macosx, then raises IPA invalid after exporting the pkg. altool can print UPLOAD SUCCEEDED and still fail Fastlane on a checksum 500.
Four device reports, all in the player: - Leaving the player left the stream running. Nothing tore the session down — Menu on tvOS popped the route and the AVPlayer played on behind the browse grid, and closing the macOS window only paused a still-loaded item. PlaybackSession.stop(_:) is the single exit, identity-checked so a route tearing down after the next film claimed the session cannot stop that one, and it writes a final watch mark before the stream goes. - iOS had no close button. An embedded AVPlayerViewController draws a transport bar and nothing else, and this route hides the navigation bar, so the film could not be left at all. The player is now presented modally full screen, which is where AVKit's own Done button comes from; the exit signal there is the dismissal delegate, because onDisappear fires when the player *presents*. - The iPhone was silent because the app never configured an audio session: the default .soloAmbient is muted by the Ring/Silent switch by definition, and the volume keys move the ringer. PlaybackAudioSession sets .playback / .moviePlayback around every stream, and UIBackgroundModes: audio is declared for the iOS SDK only — also the prerequisite PiP was always missing. - Subtitles turned themselves on over macOS video. Automatic media selection follows the system caption settings, whose Automatic display type exists to show captions when media is muted; it is now off, and off tvOS the selection is made explicitly — audio keeps the master's DEFAULT (HLSAudioLabeler's ranked dub), legible starts empty, and the system menu still overrides both. Also: the macOS window keys its player on the request, so replaying a closed film builds a new screen, while replaying the film already in the window just brings it forward instead of rebuilding over a live stream. Validation pending — not yet watched on a device. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01M4Vmyp7wEsLe72pBzcwcPV
The first CI run on this branch (nothing compiles on a branch push — CI is main and PRs only) failed both device platforms: - iOS: `playerViewControllerDidEndDismissalTransition` is marked unavailable in the iOS 26 SDK, so implementing it is a build error, not a callback that never fires. AVKit therefore cannot report Done at all; the exit signal is the host controller's `viewDidAppear` once the presentation has gone, which was already in place as the backstop. - tvOS: `AVAudioSession.RouteSharingPolicy.longFormVideo` is API_UNAVAILABLE(tvos) — the constant does not compile, so it cannot be attempted and caught. The policy is fenced to iOS; tvOS takes the plain .playback / .moviePlayback category. Both recorded in the player-avkit skill as Apple API limitations to re-probe on the next SDK. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01M4Vmyp7wEsLe72pBzcwcPV
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01M4Vmyp7wEsLe72pBzcwcPV
Leaving the player now ends the film on every platform, iOS presents the system player so AVKit draws its own close button, the audio session is configured so the iPhone is not muted by the ringer switch, and automatic media selection no longer turns subtitles on by itself. Green on CI for iOS, tvOS and macOS; not yet watched on a device.
Turning automatic media selection off stopped the muted transcription on macOS and took the legitimate half with it: a viewer whose system says Closed Captions + SDH got nothing, which playback-tracks.md already forbids — with no history, the app mirrors the system. The gap is older than that change. `configureSubtitles()` was fenced to tvOS, so off it the per-season memory, the system caption setting and the foreign-audio rule decided nothing at all and AVFoundation's criteria were the whole policy. The decision is now asked for on every platform; only the rendering differs — tvOS draws sidecar SRT itself, everywhere else the answer is carried to the master's own WebVTT renditions once the item loads. `SubtitleRenditions` is the bridge, in KinoPubBackend beside `AudioRenditions` and tested without an asset: the API list and the master share no id, so the pairing is language plus position within that language — the pair SubtitleTrack.ordinal already exists for. It assumes a master lists a language's renditions in the API's order; when it does not, the fallbacks still land on the right language, and nothing in that language means no subtitles rather than a wrong line. `renditionLanguageCode` moves out of the tvOS fence to witness both protocols. The audio ledger stays tvOS-only: off tvOS the dub is still the master's DEFAULT, which is HLSAudioLabeler's ranking rather than the resolver's scopes. Noted in the roadmap as the other half. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01M4Vmyp7wEsLe72pBzcwcPV
The subtitle fix showed the shape of the gap; this closes it. - Audio too. configureDefaultAudioWhenReady / applyAudibleGroup / persistAudioSelectionIfNeeded were tvOS-only, so the iPhone and the Mac played whatever the master marked DEFAULT and the per-season dub memory never applied. Unfenced, along with the AudioRendition conformance. - Picks made in the system menu are learned off tvOS. There is no picker of ours there and AVKit reports nothing, so the watch-mark tick reads currentMediaSelection — the trick tvOS already used for audio, now covering subtitles too, on the same episodes-watched floor. "Off" is written down; an item that offered no subtitles teaches nothing. - Matching no longer rests on order. Pairing happens inside one language and one kind (forced / SDH); position separates only tracks that are otherwise identical, counted from both lists in the same session rather than carried over from an earlier episode. Reading a pick back takes an index, because two renditions of one language and kind are equal in everything the protocol can see. - One language table. SubtitleTracks.languageKey resolves through LanguageNames instead of keeping a second, shorter copy — the copy was missing uzb and phi, which kino.pub ships, so they matched nothing while their names rendered correctly. phi → fil added; ai, their machine-translated Russian, is deliberately not an alias of ru, since an item can carry both, and gets a name of its own. - Tests where the breakage was. PlayerTrackSelectionTests lives in the app bundle, so the player wiring is covered on the iOS and tvOS simulators rather than only macOS swift test; PlayerManager takes an injectable PlaybackPreflight to make that possible. CI now runs on claude/** pushes — the branch that broke both device platforms compiled nowhere until a PR existed. No player UI is added: subtitle settings stay in Settings, and no sidecar picker comes back. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01M4Vmyp7wEsLe72pBzcwcPV
Caught by the branch CI this same commit turned on: the function was compiled on tvOS while `applyLegibleGroup` lives in the non-tvOS section, so tvOS failed with "no member 'applyLegibleGroup'" while iOS and macOS built. Audited every symbol that sits inside a platform fence against its call sites at the same time; the rest line up. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01M4Vmyp7wEsLe72pBzcwcPV
TrackResolver now decides subtitles and dub on iOS and macOS too, not only tvOS: the system caption setting is an input to it rather than a competing authority, picks made in the system menu are learned on the watch-mark tick, and renditions are matched by language and kind rather than by position. One language table, so uzb/phi stop matching nothing and ai stays its own language. Player tests now run on the iOS and tvOS simulators, and CI runs on claude/** pushes — the branch that broke tvOS compiled nowhere before. Green on CI (run 178); not yet watched on a device.
externalMetadata was only populated when the playing thing was a MediaItem. An Episode is not one and carries none of its parent's facts, so a series on Apple TV got a title, a "Season 2, Episode 5" line and an empty panel — no description, no genres, no artwork. That is the surface that uses the panel most. titleContext resolves the item behind the playback: the film, or the series snapshot LocalWatchProgressStore already holds — the same one PlaybackSession reads to pick a dub, so nothing is fetched for it. The mapping itself moved to PlaybackMetadata in KinoPubBackend, pure and asserted without an asset: title, subtitle, description, genres and now the year, with empty values omitted rather than sent as empty strings. Artwork is the title's poster, falling back to the episode's own still, and the fetch checks the item is still current before writing so a late poster cannot repopulate a stream the viewer already left. Capability badges are not reachable this way: no metadata identifier carries one. On tvOS that belongs to customInfoViewControllers, which is still ahead of us. Recorded in the skill so the next pass does not go looking. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01M4Vmyp7wEsLe72pBzcwcPV
An episode now carries its series' description, genres, year and poster into the system player's info panel instead of showing a title, an S/E line and nothing else. The mapping is PlaybackMetadata in KinoPubBackend, asserted without an asset. Green on CI (run 180); not yet watched on a device.
- HLSAudioLabeler collapses per-quality audio groups into one logical group and labels renditions from the API track list, so the Audio menu shows the dubs the site lists instead of 8 identical "Russian" rows; the master loader gains an overall resource timeout and start/finish logging. - tvOS subtitles are the system's again: the sidecar overlay, custom menu and dual-subtitle settings rows are parked behind FeatureFlags.tvOSSidecarSubtitles (off). - A prepare watchdog turns a stalled stream load (AVFoundation reports nothing) into the failure alert after 25s, which now offers Try Again / Close instead of a lone OK. - tvOS Up Next for series via the system AVContentProposal, sourced from the cached series payload. - Auth: no .resolving flip when already signed in, refresh shielded from .task cancellation, one grace round for a rotation-race rejection; audio session (de)activation moved off the main thread. - FocusLog defaults to off. Co-authored-by: Cursor <cursoragent@cursor.com>
Author
|
Opened against the wrong repo — moved to the fork. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
HLSAudioLabelernow collapses those into one logical group and labels the survivors from the API's track list (kind, studio, channels), so the system Audio menu shows the 3–4 dubs the site lists instead of 8–9 identical "Русский" rows. The master loader also gets an overall resource timeout and start/finish logging, so a hung fetch is visible in diagnostics.FeatureFlags.tvOSSidecarSubtitles(off) — kept compiled for the dual-subtitle stage, not used.AVContentProposal— next episode resolved from the cached series payload, stock panel, 10 s auto-accept, in-place stream swap..resolvingflip when already signed in, refresh shielded from.taskcancellation (rotation race → forced logout), one grace round for a rejection right after a cancelled refresh;AVAudioSession(de)activation moved off the main thread.Note:
Localizable.xcstringsis re-serialized in Xcode's canonical spacing — one noisy diff, clean ones after. Real string changes: the prepare-timeout message (RU+EN).Test plan
Made with Cursor