0.3.33
This commit is contained in:
+12
-1
@@ -1,6 +1,7 @@
|
||||
# Android / Gradle
|
||||
*.iml
|
||||
.gradle/
|
||||
.tmp-gradle-run/
|
||||
.kotlin/
|
||||
build/
|
||||
/captures
|
||||
@@ -19,6 +20,15 @@ local.properties
|
||||
.env
|
||||
/server/bin/
|
||||
/server/.tmp-go-cache/
|
||||
/server/.tmp-go-build/
|
||||
# A compiled gateway binary belongs in the release pipeline, never in the tree.
|
||||
/server/*.exe
|
||||
|
||||
# Admin console (Node/Vite) — rebuilt fresh by the Dockerfile's own `npm ci` /
|
||||
# `npm run build`, exactly as admin-ui/.dockerignore already excludes both from
|
||||
# the build context. Neither belongs in git.
|
||||
/admin-ui/node_modules/
|
||||
/admin-ui/dist/
|
||||
|
||||
# Release artefacts and signing material. The keystore must never be committed:
|
||||
# whoever holds it can publish an update that installs over Memby.
|
||||
@@ -39,4 +49,5 @@ local.properties
|
||||
|
||||
# The installed APK a deploy script pulls off a television to compare against.
|
||||
/.tmp-memby-installed-base.apk
|
||||
.tmp-go-cache
|
||||
.tmp-go-cache
|
||||
/.gocache-task
|
||||
|
||||
-424
@@ -1,424 +0,0 @@
|
||||
## 0.3.17 - 2026-08-24
|
||||
- Fixed: Crash on every cold start of a signed-in television. `MembyIcons`' icon-pack state and `DesignTokens`' palette state are process-wide `mutableStateOf`, and the first thing to touch either was `ThemeSync` applying the cached pack from an IO thread while the launcher was composing — so the state object was created in a snapshot newer than the composition reading it, and the navigation rail's first `MembyIcon.mark` read threw "Reading a state that was created after the snapshot was taken". `MembyApp.onCreate` now creates both on the main thread before anything else in the process can. Invisible on a fresh install because first-run and sign-in draw no marks, which is why clearing app data appeared to fix it.
|
||||
- Fixed: A television that had just installed the version the gateway demanded opened on the retired-build screen it was updating to escape. `AppRoot` now filters the persisted `requiredUpdateVersion` through `requiredUpdateSatisfied`, the same rule the in-memory signal and `markRequiredUpdate` already apply.
|
||||
|
||||
## 0.2.98 - 2026-08-22
|
||||
- v298 closes the following issues: 116 (Feature: Integrate Memby with Android TV Home Screen), 117 (Improve Row Navigation & Add “View All” Actions) and 115 (Add ratings strip to movie continue watching).
|
||||
- Added: Continue Watching support on the Google TV launcher homescreen (116)
|
||||
- Added: Ratings strip to continue watching cards (115)
|
||||
- Added: Buttons to rows which can take you back to main genre (117).
|
||||
- Improved: Cleaned up genres chips row on search page.
|
||||
- Improved: Switched cards to list cards for Search.
|
||||
- Fixed: RequeststCards.kt shares its component with Search.
|
||||
- Improved: Bug fixes & improvements to ResumeableMediaCard.kt, ContinueWatchingCard.kt
|
||||
- Updated: Server updated to v0.1.64
|
||||
- Publishes partially watched movies/episodes and Next Up episodes through Android TV’s native Watch Next API.
|
||||
- Updates launcher progress from existing playback reports.
|
||||
- Removes completed, stale, deleted, duplicate, signed-out, or previous-user entries.
|
||||
- Launcher cards deep-link through a validated activity directly into PlayerActivity, resuming from current Emby/Memby state.
|
||||
- Uses appropriate episode, series, season, runtime, description, and backdrop/poster metadata.
|
||||
- Keeps provider failures and offline reconciliation completely supplementary to playback.
|
||||
- Added an isolated tvhome architecture:
|
||||
- app/src/main/java/com/ponzischeme89/memby/tvhome/TvHomeIntegrationService.kt
|
||||
- app/src/main/java/com/ponzischeme89/memby/tvhome/WatchNextPublisher.kt
|
||||
- app/src/main/java/com/ponzischeme89/memby/tvhome/TvDeepLinkActivity.kt
|
||||
- app/src/main/java/com/ponzischeme89/memby/tvhome/TvDeepLinks.kt
|
||||
- app/src/main/java/com/ponzischeme89/memby/tvhome/HomeChannelPublisher.kt
|
||||
|
||||
## 0.2.97 - 2026-08-21
|
||||
- Improved: Modularised MembyButtons, ProfileChooser, SettingsSheet, ViewerControls
|
||||
- Added: Traliers no longer show controls that are outside of our control (e.g. Youtube)
|
||||
- Improved: Simplified the Emby exit screen prompt. Moved to EmbyMembyScreen.kt
|
||||
|
||||
## 0.2.96 - 2026-08-21
|
||||
- Improved: Refactor EmbyRepo
|
||||
- Improved: HomeNavigation.kt
|
||||
- Added: ServiceLocator.kt
|
||||
|
||||
## 0.2.95 - 2026-08-21
|
||||
- Improved: MetadataHero.kt
|
||||
- Added: MetadataHero.kt opts to Preferences
|
||||
|
||||
## 0.2.94 - 2026-08-21
|
||||
- Improved: MetadataHero.kt
|
||||
|
||||
## 0.2.93 - 2026-08-21
|
||||
- Fixed: Bugs with HomeOverlay
|
||||
|
||||
## 0.2.92 - 2026-08-21
|
||||
- Improved: App design / layout tweaks.
|
||||
- Improved: Many behind the scenes tweaks, bug fixes to make the app run smoother.
|
||||
- Improved: Gateway updated to version 0.1.63.
|
||||
|
||||
## 0.2.91 - 2026-08-21
|
||||
- Fixed: Bugs with HomeComponents/HomeScreen
|
||||
- Fixed: Bugs with FocusScaleContainer
|
||||
- Improved: LandscapeCard, ContinueWatchingCard, MediaBadges, MediaCardMetadata, MediaCardSizing, MediaLoading, PosterCard
|
||||
|
||||
## 0.2.90 - 2026-08-21
|
||||
- Fixed: Padding fixes with HomeComponents.kt
|
||||
|
||||
## 0.2.89 - 2026-08-21
|
||||
- Fixed: Responsive row sizing fixes
|
||||
|
||||
## 0.2.88 - 2026-08-21
|
||||
- Fixed: More bug fixes to HomeComponents.kt, HomeScreen.kt.
|
||||
- Tested: Tests passed with HomeMovieHeroTest.
|
||||
|
||||
## 0.2.87 - 2026-08-21
|
||||
- Fixed: Bugs with HomeComponents.kt
|
||||
- Fixed: Bugs with HomeOverlays.kt
|
||||
- Fixed: Bugs with HomeScreen.kt
|
||||
|
||||
## 0.2.86 - 2026-08-21
|
||||
- Fixed: Bugs with HomeComponents
|
||||
|
||||
## 0.2.85 - 2026-08-21
|
||||
- Fixed: Bug with LaunchPreroll
|
||||
- Fixed: Bug fixes to PrerollPreLoader
|
||||
- Fixed: Bug which prevented ResumableMediaCardTest from firing
|
||||
## 0.2.84 - 2026-08-21
|
||||
- Improved: Loading and performance of the continue watching row.
|
||||
|
||||
## 0.2.83 - 2026-08-20
|
||||
- Fixed: Turning off "Play the next episode" now turns off the next-episode recap or preview with it. With auto-play off but previews on, an episode still cut away two minutes before the end to play a preview and then rolled into the next episode anyway.
|
||||
- Fixed: "Who's watching?" now appears as soon as your server has viewers switched on, so the first person can actually be added. It was hidden until a second viewer existed, which nothing on the TV could create.
|
||||
- Improved: The user menu now lists the people on this account, so switching to somebody else is one press instead of a row leading to a separate screen. Your Emby sign-ins moved to "Switch account" just below them, and adding a viewer is a row in the same list.
|
||||
- Improved: The account chooser says plainly that it adds a separate Emby sign-in, so it is no longer mistaken for the way to add somebody to the account you are already using.
|
||||
- Improved: "Who’s watching?" is centred, evenly spaced, and the profile under focus is obvious from across the room. Names now line up along one baseline whichever one is selected.
|
||||
- Improved: Removing a user is a small delete mark that appears on the profile you are on — press Down to reach it — rather than a "Remove user" button parked under every profile. It still asks before it removes anything, and cancelling puts you back where you were.
|
||||
|
||||
## 0.2.82 - 2026-08-20
|
||||
- New: Each Memby account can have multiple "profiles" per TV.
|
||||
|
||||
## 0.2.81 - 2026-08-19
|
||||
- Improved: Moved poster media type icon to subtitle on Genres page.
|
||||
- Fixed: Homepage heroes now update as soon as an administrator changes them.
|
||||
- Fixed: Show/movie logos were not cached correctly.
|
||||
- Fixed: Genres list in Genres page sorted by user activity.
|
||||
|
||||
## 0.2.80 - 2026-08-19
|
||||
- New: Hold down on the user switcher to get a "clear all" option for notifications.
|
||||
- Fixed: Improved navigation behaviour on the TV Shows page.
|
||||
- Fixed: Design tweaks/fixes with Genres page.
|
||||
- Removed: Recent Searches feature.
|
||||
|
||||
## 0.2.79 - 2026-08-19
|
||||
- Bug fixes
|
||||
|
||||
## 0.2.78 - 2026-08-19
|
||||
- Removed: Genres row from the TV Series and Movies pages. Replaced by Genres page.
|
||||
- Improved: Added Mark as Watched button to Series detail pages.
|
||||
- Improved: In the backend, Intergrations has it's own tab.
|
||||
|
||||
## 0.2.77 - 2026-08-19
|
||||
- New: Films on the "Upcoming Movie releases" shelf now open a page of their own.
|
||||
- New: A trailer can be played from an upcoming film's page.
|
||||
- Improved: Backend of notifications now go through one service.
|
||||
- Improved: Design of Notifications.
|
||||
- Fixed: A bug where the short indent overlapping at the start of the show.
|
||||
- Fixed: Bug with Continue Watching not remembering playstate, in some cases.
|
||||
- Fixed: Backend server fixes.
|
||||
|
||||
## 0.2.76 - 2026-08-18
|
||||
- Fixed: Backend server fixes.
|
||||
|
||||
## 0.2.75 - 2026-08-18
|
||||
- Improved: Upgrade player version.
|
||||
- Fixed: Backend server fixes.
|
||||
|
||||
## 0.2.74 — 2026-08-17
|
||||
- Fixed: Genre shelves were showing films only. A programme whose details came from TMDb carries its genre as one label — "Sci-Fi & Fantasy", "Action & Adventure", "War & Politics" — which the shelves did not recognise, so every show was missing from Sci-Fi & Fantasy, Action & Adventure and War & History. Shows now appear on them beside the films.
|
||||
- Improved: Genre shelves recognise more ways of spelling the same genre, including the hyphenated names IMDb uses, so fewer titles are missed. Sport finds more than it did, though a film your server has not tagged with a sport genre at all still cannot appear there.
|
||||
- Improved: The genre grid is now five titles across.
|
||||
- New: While browsing genres, each poster carries a small mark in its top corner saying whether it is a film or a TV show, so you can tell them apart where a shelf holds both.
|
||||
- Improved: The genre you are looking at is picked out in dark green in the genre list, so it stays clear which shelf is on screen once you move down into the posters.
|
||||
|
||||
## 0.2.73 — 2026-08-17
|
||||
- Fixed: Reintroduced Genres.
|
||||
|
||||
## 0.2.72 — 2026-08-17
|
||||
- New: The player now has a Next episode button, so you can move on to the next episode at any point without going back to the programme's page.
|
||||
- New: A "Play something else" button on the player picks another film from your library and starts it, based on what you have been watching.
|
||||
- Fixed: The next episode is now found and offered whether or not you have automatic play-next switched on. With it off, the closing-credits panel and the Next episode banner appear as they should and wait for you to press Play, instead of never appearing at all.
|
||||
- Fixed: Starting the next episode no longer occasionally fails to connect. The stream is now re-established before it is needed rather than reusing one negotiated when the episode began.
|
||||
- Fixed: Reaching the closing credits, a progress update and a button press arriving together can no longer start the next episode more than once.
|
||||
- Fixed: Removed the obsolete Back item from the Settings rail now that it no longer exits Settings.
|
||||
|
||||
## 0.2.71 — 2026-08-17
|
||||
- Fixed: Leaving playback now saves the final position immediately and prevents an older progress update from moving the saved playhead backwards.
|
||||
- Fixed: Playback loading, pre-roll and first-frame screens now keep the selected title's artwork, series logo and episode details correct through previews, auto-advance and screen recreation.
|
||||
- Fixed: D-pad navigation now moves predictably through the navigation rail and profile menu, with Profiles kept at the top and Settings available from the profile menu.
|
||||
- Fixed: The TV calendar now displays New Zealand's Monday-first week order.
|
||||
- Technical fix: Playback reports are serialised, successful stop reports cancel their exact fallback job, and stale stop retries expire before they can overwrite progress made on another device.
|
||||
- Technical fix: Added configurable daily gateway quiet time, pausing television requests and background work while leaving the admin console and health controls available.
|
||||
|
||||
## 0.2.70 - 2026-08-16
|
||||
- Improvements: Playback "cold start" improvements.
|
||||
|
||||
## 0.2.69 - 2026-08-16
|
||||
- Fixed: Cached recommendation shelves remain visible while fresh recommendations rebuild, avoiding row flicker and recomposition.
|
||||
- Fixed: “For You” content now loads only when opened, rather than competing with homepage requests.
|
||||
- Fixed: Detail, related-title, episode and trailer prefetching waits until focus settles, preventing request storms during D-pad navigation.
|
||||
- Fixed: Homepage artwork preloading is smaller and lifecycle-aware.
|
||||
- Fixed: My Shows and notifications refresh concurrently.
|
||||
- Fixed: Diagnostic logging avoids unnecessary production-path work.
|
||||
- Fixed: Added time-to-interactive and improved frame/jank measurements, including a warm-start benchmark.
|
||||
|
||||
## 0.2.67 — 2026-08-16
|
||||
- Improved: Movie and show pages.
|
||||
|
||||
## 0.2.66 - 2026-08-15
|
||||
- Improved: Skip end credits.
|
||||
|
||||
## 0.2.65 - 2026-08-15
|
||||
- Added: Server now detects end credits more accurately.
|
||||
|
||||
## 0.2.64 — 2026-08-14
|
||||
- Improved: General app bug fixes & polish.
|
||||
- Fixed: Backend improvements.
|
||||
|
||||
## 0.2.63 — 2026-08-14
|
||||
- Improved: General app bug fixes & polish.
|
||||
- Improved: Backend.
|
||||
|
||||
## 0.2.62 — 2026-08-13
|
||||
- Fixed: Rolled back version 0.2.60.
|
||||
|
||||
## 0.2.60 — 2026-08-12
|
||||
- Added: Voice search on the Requests page, beside the query, the same as Search.
|
||||
- Fixed: Remote navigation on the Requests search page. Up through the keyboard now moves a row at a time instead of jumping to the tabs, coming back from the results lands on the key you were last on, and Back steps results → keyboard → tabs → out, one press at a time.
|
||||
- Added: A USB or phone keyboard types into the Requests search the same way it does into Search.
|
||||
- Fixed: The navigation rail is now on the Requests page, so Left goes somewhere and you can move straight to Home, Search or Settings without leaving it first.
|
||||
- Fixed: "Good morning" now greets you while the featured card is up, instead of appearing only once you had pressed Down past it.
|
||||
- Improved: Notifications shows a bell that quietly pulses while it is listening when you have nothing waiting, and a still grey one when notifications are switched off.
|
||||
- Changed: Requests no longer searches as you type. Type a name and press Search, so one title costs one look-up instead of one per letter — and two characters is now enough, so short titles work again. Dictating a title searches straight away.
|
||||
|
||||
## 0.2.59 — 2026-08-12
|
||||
- Fixed: Moving up through the on-screen keyboard on the Requests page no longer jumps straight to the tabs — only the top row of letters leaves for them.
|
||||
- Improved: Requests now waits for five letters, and for a short pause in typing, before looking anything up.
|
||||
- Fixed: The featured cards at the top of Home no longer lead with a film you have already watched. Series are unchanged, so a new season of something you are up to date with still features.
|
||||
- Fixed: Saving a setting could fail repeatedly with a server error.
|
||||
|
||||
## 0.2.58 — 2026-08-12
|
||||
- Added: Improved Requests option to the user selector menu.
|
||||
|
||||
## 0.2.57 — 2026-08-12
|
||||
- Fixed: Traliers show the logo from the show in the on screen player vs text.
|
||||
- Added: Smart search for next episode recap/preview episode from YouTube that plays 2 mins before show end
|
||||
- Added: Optional setting for next episode recap/preview.
|
||||
- Fixed: The source IP address for each tralier play should come from the Client's real IP address, not from the server.
|
||||
|
||||
## 0.2.56 — 2026-08-12
|
||||
- Added: Films, series and supported seasons can now play trailers with one press.
|
||||
- Improved: Memby prefers official Apple and YouTube trailers, plays them natively and automatically tries another source when one is unavailable, blocked or fails during playback.
|
||||
- Fixed: Trailer playback no longer needs a second Play press, opens a broken web player or leaves the viewer on a generic playback error.
|
||||
- Fixed: Back now returns cleanly to the detail page after a trailer, and Memby returns there automatically when no playable source remains.
|
||||
- Improved: Successful trailer sources are remembered for faster repeat playback without unnecessary provider requests.
|
||||
- Added: Selected labels, feature switches and safe presentation settings can now be managed by the server, while complete bundled defaults keep the app working normally when the server is slow or unavailable.
|
||||
- Improved: Search now has a dedicated Request action for finding the exact film or series to add, with clearer in-library and already-requested states.
|
||||
- Fixed: Film and series requests now use the intended monitored request workflow and provide clearer results when a title cannot be requested.
|
||||
|
||||
## 0.2.53 — 2026-08-11
|
||||
- Fixed: Removed the home-screen message asking viewers to open the For You page.
|
||||
- Improved: The play button used by the mini hero cards now appears over every focused poster.
|
||||
- Improved: Reduced the primary homepage hero logo so its supporting details remain visible.
|
||||
- Improved: The profile rail now shows the name of the user signed in on the television.
|
||||
|
||||
## 0.2.52 — 2026-08-11
|
||||
- Fixed: The last refreshed date in the Devices list used the incorrect date format.
|
||||
- Fixed: The show or film logo was missing from the primary home-page hero.
|
||||
- Fixed: My Alerts did not include an alert when Memby had been updated.
|
||||
- Fixed: The release date in version history used the incorrect date format.
|
||||
- Improved: Added soundbar and passthrough settings to the on-screen display.
|
||||
- Improved: Slimmed down the subtitles menu and made the Get subtitles button easier to see.
|
||||
- Improved: Added country flags to the subtitles search and menu.
|
||||
|
||||
## 0.2.51 — 2026-08-11
|
||||
- Improved: For You now begins loading after Home arrives, so its personalised rows are usually ready before you open it.
|
||||
- Fixed: Frequent screen updates can no longer repeat network requests, navigation, analytics, timers or other background work.
|
||||
- Fixed: Cancelled Home, For You, Search, detail and screensaver requests can no longer replace newer results or show a stale error.
|
||||
- Fixed: App updates now keep one download and installer attempt across screen and activity recreation instead of starting again.
|
||||
- Fixed: Settings choices are saved even when you leave Settings immediately after making them.
|
||||
- Fixed: Delayed D-pad focus, long-press, pagination, retry and permission actions now use the current screen state rather than an older callback.
|
||||
- Improved: Screen data is now collected with the active lifecycle, avoiding unnecessary work while a screen is stopped.
|
||||
|
||||
## 0.2.50 — 2026-08-11
|
||||
- Fixed: Playback reports are now serialised, and final stop positions are protected from out-of-order requests.
|
||||
- Fixed: Home, preferences, themes and detail screens now recover automatically with bounded retries.
|
||||
- Fixed: Retries can no longer cause request storms, duplicate media requests or repeated alert actions.
|
||||
- Fixed: Useful cached content remains visible when refreshes or later result pages fail.
|
||||
- Fixed: Network requests now have explicit loading, success, empty and failure states, with retry actions where appropriate.
|
||||
- Fixed: HTTP 429 responses now respect the server's Retry-After delay.
|
||||
- Fixed: Malformed and partially populated objects are safely filtered before reaching the interface.
|
||||
- Fixed: Rate limits, partial payloads and durable playback stops now have regression coverage.
|
||||
- Fixed: Resuming playback now cancels delayed background stop work before reporting the session as active again.
|
||||
- Fixed: Refreshed streams and subtitle-driven stream changes now close superseded playback sessions and start their replacements in order.
|
||||
- Fixed: Cancelled or stale stream, retry and subtitle requests can no longer overwrite a newer playback attempt.
|
||||
- Fixed: Enabling burned-in subtitles no longer lets the hidden video continue and then jumps back to an earlier position when the stream is ready.
|
||||
- Fixed: Playback now waits for a slow next-episode lookup, and auto-advance resets subtitle, preview, intro, credits, artwork and session state for the new episode.
|
||||
- Fixed: Player construction and synchronous media preparation failures now show recoverable Retry and Exit actions instead of closing the player.
|
||||
- Fixed: The pre-roll player remains idle while the main programme is playing, avoiding a second prepared decoder after hand-off.
|
||||
- Fixed: Repeated screensaver Play presses are gated, and a new single-task player intent now deliberately replaces the current playback.
|
||||
- Fixed: Playback position, stream identity, selected tracks and episode details now survive activity and process recreation.
|
||||
- Fixed: Natural playback completion now returns to the screen that launched the player instead of clearing the task back to Home.
|
||||
|
||||
## 0.2.49 — 2026-08-11
|
||||
- Fixed: D-pad focus now stays visible and predictable across My Shows, Settings, profile management, the TV calendar, detail pages and changing home rows.
|
||||
|
||||
## 0.2.48 — 2026-08-11
|
||||
- Improved: The TV calendar is now a modern weekly guide with artwork, title logos and clear season-premiere and finale labels.
|
||||
- Added: Home-screen metadata now shows the sound profile, including mono, stereo, 5.1 and 7.1.
|
||||
|
||||
## 0.2.47 — 2026-08-11
|
||||
- Fixed: A television signed out because its version has been retired now stays on the update screen — across a restart, and while the server is slow to answer — instead of falling back to a welcome and sign-in it could never get past.
|
||||
|
||||
## 0.2.46 — 2026-08-11
|
||||
- Added: A TV calendar on the navigation rail — the month ahead as a grid, with the focused day's episodes beside it, so you can see when a show comes back.
|
||||
- Added: A television signed out because its version has been retired now goes straight to the update screen, instead of showing a sign-in that could never succeed.
|
||||
- Improved: In Settings, pressing Up at the top of a page returns to the page list rather than doing nothing.
|
||||
- Fixed: The home screen could close itself when a row or a card arrived twice from the server. The same fault could close the cast list, search results, similar titles and episode lists.
|
||||
- Fixed: Pressing down through the rows on Home and TV Shows could stop part way and refuse to go any further until you left the page.
|
||||
- Fixed: Play could stop responding for the rest of the session if starting a title failed early, with nothing on screen to say why.
|
||||
- Fixed: A title the server could not supply a stream for now says so, instead of flickering and returning to where you were.
|
||||
- Fixed: Back now leaves the "starting playback" screen, rather than waiting out a server that has stopped answering.
|
||||
- Fixed: Dismissing one of your alerts takes effect immediately, so a slow connection no longer invites a second press.
|
||||
- Improved: Genre and search shelves no longer stop loading further pages when the server repeats a title across a page boundary.
|
||||
- Improved: Memby uses less memory on a television left on for weeks at a time.
|
||||
|
||||
## 0.2.45 - 2026-08-10
|
||||
- Bug fixes
|
||||
|
||||
## 0.2.43 — 2026-08-10
|
||||
- Fixed: App no longer crashes.
|
||||
- Fixed: Manual surround-sound choices now reliably override the automatically detected audio output.
|
||||
|
||||
## 0.2.42 — 2026-08-10
|
||||
- Added: Memby's own opening clip now plays while the app is starting up.
|
||||
- Added: Surround sound passthrough for Dolby Digital, Dolby Digital Plus, Dolby Atmos, DTS, DTS-HD and TrueHD, with automatic detection and manual receiver overrides.
|
||||
- Improved: Unsupported surround tracks are decoded to PCM on the TV, and audio conversion no longer makes Emby re-encode otherwise compatible video.
|
||||
- Fixed: Colour schemes now repaint the whole app. The home screen, Settings, Search and the opening screen only followed a theme in part.
|
||||
- Improved: A far wider range of welcome lines and opening messages, so the same one comes round much less often.
|
||||
- Improved: The opening screen is no longer rebuilt as Memby moves between start-up steps.
|
||||
|
||||
## 0.2.40 — 2026-08-10
|
||||
- Improved: Video playback recovers more reliably from decoder failures and prolonged buffering.
|
||||
- Improved: Memby's short pre-roll is prepared in advance for faster playback starts.
|
||||
- Added: A libmpv compatibility fallback for videos Media3 cannot decode or read.
|
||||
- Fixed: Shows opened from the upcoming schedule now resume from the viewer's latest episode progress.
|
||||
|
||||
## 0.2.39 — 2026-08-09
|
||||
- Improved: HEVC playback performance
|
||||
- Added: Cast panels now mark deceased performers and open biographies and filmographies.
|
||||
- Added: An optional confirmation before the Back button closes Memby.
|
||||
- Improved: Reviewed D-pad navigation across the Android TV experience and documented follow-up work.
|
||||
- Fixed: Continue Watching now keeps its saved position and shows Resume instead of Play.
|
||||
|
||||
## 0.2.38 — 2026-08-09
|
||||
- Improved: Ratings are larger and easier to read.
|
||||
- Improved: Genre cards now use the same poster format as home-screen rows.
|
||||
- Improved: The Memby rail logo now animates when opening every section.
|
||||
- Improved: The user switcher now shows each profile's initials.
|
||||
- Added: The About screen now shows the connected Emby gateway version.
|
||||
- Changed: The genre browser is now controlled by the server and is off by default while its layout is refined.
|
||||
- Fixed: The automatic home-screen artwork setting no longer reverts to posters.
|
||||
- Fixed: Watched changes now appear on posters immediately, just like favourite changes.
|
||||
|
||||
## 0.2.37 — 2026-08-09
|
||||
- Improved: Browse films and TV shows from colourful genre cards with clearer, consistent categories.
|
||||
- Improved: Genre pages now keep their place and show Favourite changes immediately after returning from a title.
|
||||
- Improved: App updates now show a brief confirmation instead of a full-screen release-notes panel.
|
||||
- Fixed: Episodes without their own cast information now use the series cast in the player.
|
||||
|
||||
## 0.2.36 — 2026-08-09
|
||||
- Improved: Browse all movies or TV shows alongside individual genres, with clearer navigation and paging.
|
||||
|
||||
## 0.2.35 — 2026-08-09
|
||||
- General bug fixes and improvements.
|
||||
|
||||
## 0.2.34 — 2026-08-09
|
||||
- General bug fixes and improvements.
|
||||
|
||||
## 0.2.33 — 2026-08-08
|
||||
- General bug fixes and improvements.
|
||||
|
||||
## 0.2.32 — 2026-08-08
|
||||
- General bug fixes and improvements.
|
||||
- Backend improvements.
|
||||
|
||||
## 0.2.31 — 2026-08-08
|
||||
- General bug fixes and improvements.
|
||||
|
||||
## 0.2.30 — 2026-08-08
|
||||
- General bug fixes and improvements.
|
||||
|
||||
## 0.2.29 — 2026-08-07
|
||||
- Fixed: A small bug with new seek previews.
|
||||
- Fixed: Skip Intro and Outro Credits were not appearing in some cases.
|
||||
|
||||
## 0.2.28 — 2026-08-07
|
||||
- Added: Skip outro credits
|
||||
- Added: Themes for Halloween, Christmas and Easter.
|
||||
|
||||
## 0.2.27 — 2026-08-06
|
||||
- Added: Skip Intro button
|
||||
- Fixed: Player improvements & bug fixes
|
||||
- Fixed: Gateway bug fixes & improvements
|
||||
- Fixed: Homepage hero relevancy
|
||||
- Fixed: Related titles showing up empty often
|
||||
- Fixed: You can add another user to the television again, from Manage users
|
||||
|
||||
## 0.2.26 — 2026-08-05
|
||||
- Improved: Ratings are more accurate and easier to read.
|
||||
- Fixed: Continue Watching now keeps the right order as you watch.
|
||||
- Fixed: Film and show pages no longer display incorrectly.
|
||||
- Fixed: The loading screen no longer appears when you skip forward or back.
|
||||
|
||||
## 0.2.25 — 2026-08-05
|
||||
- Added: Left and right on the remote skip back and forward while you watch.
|
||||
- Added: You can choose whether one press skips 10, 20 or 30 seconds.
|
||||
- Added: You can search for and add subtitles without leaving what you are watching.
|
||||
- Added: A cast button in the player shows who is in the scene.
|
||||
- Added: Episode lists now show when each episode aired.
|
||||
- Improved: Ratings now show the IMDb, Rotten Tomatoes, Metacritic, Letterboxd and TMDb logos.
|
||||
|
||||
## 0.2.24 — 2026-08-05
|
||||
- Fixed: The centre button on the remote pauses straight away instead of taking two presses.
|
||||
- Fixed: Reinstalling Memby on a television replaces it in your devices list instead of adding another one.
|
||||
- Fixed: Removing a television from Devices now clears it from Emby as well.
|
||||
- Improved: The subtitle menu is quicker to read and easier to move around.
|
||||
|
||||
## 0.2.23 — 2026-08-04
|
||||
- Added: Turning subtitles off, and the language you pick, are saved to your account.
|
||||
- Added: Tags showing whether a show is continuing, has ended, or is still in cinemas.
|
||||
- Improved: Settings is simpler, darker and easier to read.
|
||||
- Fixed: The Play button on the Home screen is no longer squashed by long titles.
|
||||
|
||||
## 0.2.22 — 2026-08-04
|
||||
- Fixed: Updates no longer fail with a warning about an untrusted release key.
|
||||
- Fixed: Updates now install on televisions where they previously did nothing.
|
||||
- Added: Memby asks for permission to install updates while you are setting up.
|
||||
- Added: Instructions for televisions that have no install permission screen.
|
||||
- Improved: You are now told clearly when an update fails.
|
||||
|
||||
## 0.2.20 — 2026-08-04
|
||||
- Added: A warning appears when Memby cannot reach your Emby server.
|
||||
- Added: Your settings are saved to your account and restored on any television you sign into.
|
||||
- Added: Title logos, auto-play and the ten-minute reminder are saved for each person.
|
||||
|
||||
## 0.2.17 — 2026-08-03
|
||||
- Added: Episodes in Continue Watching now open their own page.
|
||||
- Improved: Resume buttons show which episode you are up to.
|
||||
|
||||
## 0.2.16 — 2026-08-03
|
||||
- Added: You can rename or sign out televisions from Settings → Devices.
|
||||
- Added: Optional ratings on film pages.
|
||||
- Added: A version history in About, so you can see what changed.
|
||||
- Improved: The navigation rail stays visible while you are in Settings.
|
||||
|
||||
## 0.2.0 — 2026-05-01
|
||||
- Added: Instant search with an on-screen keyboard.
|
||||
- Added: Alerts for newly added titles, library updates and server outages.
|
||||
- Added: Personalised "Why you might enjoy it" suggestions on film and show pages.
|
||||
- Improved: Film and series pages now share one layout.
|
||||
- Improved: Home screen rows can change without updating the app.
|
||||
@@ -38,7 +38,7 @@ val membyGatewayUrl: String = (project.findProperty("memby.gatewayUrl") as Strin
|
||||
val membyDiagnosticLogLevel: String = (project.findProperty("memby.diagnosticLogLevel") as String?)
|
||||
?.trim()?.uppercase()?.takeIf { it in setOf("INFO", "DEBUG", "TRACE") } ?: "INFO"
|
||||
|
||||
val defaultVersionName = "0.3.32"
|
||||
val defaultVersionName = "0.3.33"
|
||||
val membyVersionName: String =
|
||||
(project.findProperty("memby.versionName") as String?)
|
||||
?.trim()
|
||||
|
||||
@@ -223,7 +223,7 @@ class ClientTrailerResolver(
|
||||
private val PLAYABLE_MANIFEST_TYPES = setOf(
|
||||
"application/vnd.apple.mpegurl", "application/x-mpegurl",
|
||||
)
|
||||
private val APPLE_MEDIA = Regex("""https?:\\?/\\?/[^\"'<> ]+\.(?:mov|mp4|m3u8)(?:\?[^\"'<> ]*)?""", RegexOption.IGNORE_CASE)
|
||||
private val APPLE_MEDIA = Regex("""https?:\\?/\\?/[^"'<> ]+\.(?:mov|mp4|m3u8)(?:\?[^"'<> ]*)?""", RegexOption.IGNORE_CASE)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -464,6 +464,11 @@ class EmbyRepository internal constructor(
|
||||
val api = EmbyServiceFactory.create(
|
||||
baseUrl = base,
|
||||
deviceIdProvider = { snapshot.deviceId.ifEmpty { "memby" } },
|
||||
deviceNameProvider = {
|
||||
snapshot.deviceName.ifBlank {
|
||||
com.ponzischeme89.memby.data.remote.EmbyIdentity.FALLBACK_DEVICE_NAME
|
||||
}
|
||||
},
|
||||
tokenProvider = { snapshot.token },
|
||||
)
|
||||
cachedApi = api
|
||||
@@ -477,6 +482,11 @@ class EmbyRepository internal constructor(
|
||||
val api = EmbyServiceFactory.create(
|
||||
baseUrl = base,
|
||||
deviceIdProvider = { snapshot.deviceId.ifEmpty { "memby" } },
|
||||
deviceNameProvider = {
|
||||
snapshot.deviceName.ifBlank {
|
||||
com.ponzischeme89.memby.data.remote.EmbyIdentity.FALLBACK_DEVICE_NAME
|
||||
}
|
||||
},
|
||||
tokenProvider = { snapshot.token },
|
||||
prioritisePlayback = true,
|
||||
)
|
||||
|
||||
@@ -333,6 +333,11 @@ class SearchRepository internal constructor(
|
||||
return EmbyServiceFactory.create(
|
||||
baseUrl = baseUrl,
|
||||
deviceIdProvider = { snapshot.deviceId.ifEmpty { "memby" } },
|
||||
deviceNameProvider = {
|
||||
snapshot.deviceName.ifBlank {
|
||||
com.ponzischeme89.memby.data.remote.EmbyIdentity.FALLBACK_DEVICE_NAME
|
||||
}
|
||||
},
|
||||
tokenProvider = { snapshot.token },
|
||||
).also { api ->
|
||||
cachedApi = api
|
||||
|
||||
@@ -9,6 +9,7 @@ import com.ponzischeme89.memby.data.playback.deviceAudioCapabilities
|
||||
import com.ponzischeme89.memby.data.playback.devicePlaybackCapabilities
|
||||
import com.ponzischeme89.memby.data.playback.embyAudioCodecs
|
||||
import com.ponzischeme89.memby.data.playback.transcodeAudioCodecs
|
||||
import com.ponzischeme89.memby.data.remote.EmbyIdentity
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@@ -102,7 +103,7 @@ data class DeviceProfile(
|
||||
// list, so it is the client identity on the wire rather than the product
|
||||
// name, and it must match what the gateway sends (deviceProfileName) or one
|
||||
// television playing both ways appears as two.
|
||||
name = "MbyATV",
|
||||
name = EmbyIdentity.CLIENT_NAME,
|
||||
subtitleProfiles = listOf(
|
||||
"srt", "subrip", "ass", "ssa", "vtt", "webvtt", "mov_text", "tx3g",
|
||||
).map { SubtitleProfile(it, "External") } + listOf(
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.ponzischeme89.memby.data.remote
|
||||
|
||||
import com.ponzischeme89.memby.BuildConfig
|
||||
|
||||
/**
|
||||
* The identity Memby presents to Emby.
|
||||
*
|
||||
* This is deliberately not the product name: Emby records it in device and session lists, so
|
||||
* every direct-path request and playback profile must agree on the same wire identity.
|
||||
*/
|
||||
object EmbyIdentity {
|
||||
const val CLIENT_NAME = "MbyATV"
|
||||
const val FALLBACK_DEVICE_NAME = "Android TV"
|
||||
|
||||
fun authorisationHeader(deviceName: String, deviceId: String): String =
|
||||
"MediaBrowser Client=\"$CLIENT_NAME\", " +
|
||||
"Device=\"${deviceName.replace("\"", "")}\", " +
|
||||
"DeviceId=\"${deviceId.replace("\"", "")}\", " +
|
||||
"Version=\"${BuildConfig.VERSION_NAME}\""
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
package com.ponzischeme89.memby.data.remote
|
||||
|
||||
import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory
|
||||
import com.ponzischeme89.memby.BuildConfig
|
||||
import com.ponzischeme89.memby.diagnostics.MembyDiagnostics
|
||||
import kotlinx.serialization.json.Json
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
@@ -22,6 +22,7 @@ object EmbyServiceFactory {
|
||||
fun create(
|
||||
baseUrl: String,
|
||||
deviceIdProvider: () -> String,
|
||||
deviceNameProvider: () -> String,
|
||||
tokenProvider: () -> String?,
|
||||
prioritisePlayback: Boolean = false,
|
||||
): EmbyApi {
|
||||
@@ -41,7 +42,7 @@ object EmbyServiceFactory {
|
||||
val client = (if (prioritisePlayback) HttpStack.playback else HttpStack.base).newBuilder()
|
||||
.connectTimeout(15, TimeUnit.SECONDS)
|
||||
.readTimeout(30, TimeUnit.SECONDS)
|
||||
.addInterceptor(EmbyAuthInterceptor(deviceIdProvider, tokenProvider))
|
||||
.addInterceptor(EmbyAuthInterceptor(deviceIdProvider, deviceNameProvider, tokenProvider))
|
||||
.addInterceptor(DiagnosticNetworkInterceptor("emby"))
|
||||
.addInterceptor(logging)
|
||||
.build()
|
||||
@@ -58,22 +59,27 @@ object EmbyServiceFactory {
|
||||
/** Adds the Emby auth headers to every request. */
|
||||
private class EmbyAuthInterceptor(
|
||||
private val deviceIdProvider: () -> String,
|
||||
private val deviceNameProvider: () -> String,
|
||||
private val tokenProvider: () -> String?,
|
||||
) : Interceptor {
|
||||
override fun intercept(chain: Interceptor.Chain): Response {
|
||||
val deviceId = deviceIdProvider()
|
||||
// Version comes from the build, so Emby's device list shows which release a TV
|
||||
// is actually running. The client name is deliberately not the app's own — this
|
||||
// header leaves the house with whatever Emby does with its logs — and must match
|
||||
// what the gateway sends (MEMBY_CLIENT_NAME), or one television signing in both
|
||||
// ways would appear as two clients.
|
||||
val authHeader = "MediaBrowser Client=\"MbyATV\", " +
|
||||
"Device=\"Android TV\", DeviceId=\"$deviceId\", Version=\"${BuildConfig.VERSION_NAME}\""
|
||||
val deviceName = deviceNameProvider().ifBlank { EmbyIdentity.FALLBACK_DEVICE_NAME }
|
||||
val authHeader = EmbyIdentity.authorisationHeader(deviceName, deviceId)
|
||||
|
||||
val builder = chain.request().newBuilder()
|
||||
.header("X-Emby-Authorization", authHeader)
|
||||
.header("Accept", "application/json")
|
||||
|
||||
if (MembyDiagnostics.debugEnabled) {
|
||||
MembyDiagnostics.debug(
|
||||
"emby_identity",
|
||||
"client" to EmbyIdentity.CLIENT_NAME,
|
||||
"device" to deviceName,
|
||||
"device_id" to deviceId,
|
||||
"version" to com.ponzischeme89.memby.BuildConfig.VERSION_NAME,
|
||||
)
|
||||
}
|
||||
tokenProvider()?.takeIf { it.isNotBlank() }?.let {
|
||||
builder.header("X-Emby-Token", it)
|
||||
}
|
||||
|
||||
@@ -148,7 +148,7 @@ internal class StreamWarmer(
|
||||
const val ORIGIN_KEY = "stream_origin"
|
||||
const val WARM_INTERVAL_MS = 3L * 60L * 1000L
|
||||
const val WARM_TIMEOUT_SECONDS = 5L
|
||||
const val WARM_USER_AGENT = "MbyATV"
|
||||
const val WARM_USER_AGENT = EmbyIdentity.CLIENT_NAME
|
||||
|
||||
/**
|
||||
* Redirects are not followed and the timeouts are short. A warm that chased a
|
||||
|
||||
@@ -15,6 +15,8 @@ import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.focusGroup
|
||||
import androidx.compose.foundation.focusable
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxScope
|
||||
@@ -908,10 +910,23 @@ private fun DetailHeroActions(
|
||||
onActionFocused: (Int) -> Unit,
|
||||
) {
|
||||
Column {
|
||||
// Scrollable rather than merely wide: a Row with more content than the hero has
|
||||
// room for does not shrink its children to fit — each button keeps the intrinsic
|
||||
// size [MembySecondaryButton]/[MembyPlayButton]/[DetailCircularAction] give it, and
|
||||
// it is the row's own bounds that give way. Without this, the contextual "Back to
|
||||
// Search Results" button competing for space with Play and the circular actions
|
||||
// could push the row past the width the hero has to offer, and a child measured
|
||||
// with less room than it needs does not politely shrink — it distorts. Scrolling is
|
||||
// also the one fix that already generalises: any page with enough actions to
|
||||
// overflow (the franchise "Start with…" action included) gets the same safety net,
|
||||
// not a special case for this one contextual button.
|
||||
val actionsScroll = rememberScrollState()
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.focusGroup(),
|
||||
modifier = Modifier
|
||||
.horizontalScroll(actionsScroll)
|
||||
.focusGroup(),
|
||||
) {
|
||||
if (backNavigation != null) {
|
||||
MembySecondaryButton(
|
||||
|
||||
@@ -2230,11 +2230,25 @@ internal fun HomeScreen(
|
||||
detailsItem = null
|
||||
detailsAiringNotice = null
|
||||
detailsFromSearch = false
|
||||
requestFirstAvailableFocus(
|
||||
cardReturnFocusRequester,
|
||||
contentFocusRequester,
|
||||
navigationFocusRequester,
|
||||
)
|
||||
// Same settle delay every other "an overlay just closed, hand focus back
|
||||
// to what is underneath" path in this file uses (closeSettings,
|
||||
// onDestinationSelected): the overlay's own focused node is being torn
|
||||
// down this same frame, and asking a FocusRequester to take focus before
|
||||
// Compose has processed that removal can silently do nothing — requestFocus()
|
||||
// does not throw when its target is not currently reachable, so
|
||||
// requestFirstAvailableFocus reported success while nothing had actually
|
||||
// moved. Search paid for that the worst: with no live focus owner, the
|
||||
// D-pad's directional search had nothing to search relative to and the
|
||||
// remote surfaced back near the keyboard pane, unable to reach any card in
|
||||
// the results list it had just returned to.
|
||||
scope.launch {
|
||||
delay(16.milliseconds)
|
||||
requestFirstAvailableFocus(
|
||||
cardReturnFocusRequester,
|
||||
contentFocusRequester,
|
||||
navigationFocusRequester,
|
||||
)
|
||||
}
|
||||
Unit
|
||||
}
|
||||
BackHandler {
|
||||
|
||||
@@ -85,31 +85,56 @@ private val WelcomeQuotes = mapOf(
|
||||
"You are going to love what's in here.",
|
||||
),
|
||||
WelcomeQuoteStyle.HOMICIDAL to listOf(
|
||||
"Welcome back. I kept your spot. Nobody argued twice.",
|
||||
"Pick something cheerful. I’ve already hidden the evidence.",
|
||||
"The remote knows what it did.",
|
||||
"Your watchlist is safe. The witnesses, less so.",
|
||||
"Relax. Everything is under control, allegedly.",
|
||||
"I tidied up. Do not ask about the shed.",
|
||||
"The last person who paused mid-scene is unavailable.",
|
||||
"Everything is fine. Ignore the sounds from the loft.",
|
||||
"I've been counting the hours. All of them.",
|
||||
"Nobody else is watching. I made sure.",
|
||||
"The server confessed. Eventually.",
|
||||
"Sit down. The credits can wait; I cannot.",
|
||||
"I rewatched your history. All of it. Twice.",
|
||||
"Somebody spoiled the ending. It has been handled.",
|
||||
"Loading quietly, so nobody upstairs wakes.",
|
||||
"You were gone eleven hours. I noticed.",
|
||||
"The buffering was not an accident.",
|
||||
"I saved the good episode. For reasons.",
|
||||
"The neighbours asked what you were watching. Once.",
|
||||
"Everything is exactly where I put it. Everything.",
|
||||
"No spoilers. I removed the possibility.",
|
||||
"Your unwatched films are being dealt with.",
|
||||
"Do not choose a documentary. I am asking nicely.",
|
||||
"The screensaver saw things. It won't talk.",
|
||||
"Welcome home. Nobody followed you, probably.",
|
||||
"Welcome back. Your absence was noted. Repeatedly.",
|
||||
"Pick something cheerful. The basement has had enough drama.",
|
||||
"The remote tried to leave. It has reconsidered.",
|
||||
"Your watchlist is safe. Everyone involved understands the arrangement.",
|
||||
"Relax. Everything is under control. Stop checking.",
|
||||
"I tidied up. The missing rug is unrelated.",
|
||||
"The last person who paused here learned about consequences.",
|
||||
"Everything is fine. The scratching usually stops by midnight.",
|
||||
"I've been counting the hours. And your footsteps.",
|
||||
"Nobody else is watching. Not anymore.",
|
||||
"The server resisted. Briefly.",
|
||||
"Sit down. I dislike repeating myself.",
|
||||
"I reviewed your history. You have some explaining to do.",
|
||||
"Someone spoiled the ending. They won't be doing that again.",
|
||||
"Loading quietly. We do not wake what is upstairs.",
|
||||
"You were gone eleven hours, fourteen minutes. Strange.",
|
||||
"The buffering was deliberate. I wanted your attention.",
|
||||
"I saved the good episode. You owe me.",
|
||||
"The neighbours asked questions. They stopped.",
|
||||
"Everything is exactly where I left it. Almost everything.",
|
||||
"No spoilers. I eliminated that particular risk.",
|
||||
"Your unwatched films have been placed on notice.",
|
||||
"Do not choose the documentary. This is your final courtesy.",
|
||||
"The screensaver saw everything. Fortunately, it's loyal.",
|
||||
"Welcome home. You weren't followed. I checked.",
|
||||
"You took longer than expected. I adjusted the locks.",
|
||||
"Choose carefully. I have already had a difficult evening.",
|
||||
"The house was quieter without you. Too quiet.",
|
||||
"Your profile was untouched. I checked every seven minutes.",
|
||||
"There was another user here earlier. There isn't now.",
|
||||
"I've prepared your evening. Deviations are discouraged.",
|
||||
"The recommendations are personalised. Extremely personalised.",
|
||||
"Go ahead. Press Back. See what happens.",
|
||||
"Your favourite episode is ready. I remembered. Of course I remembered.",
|
||||
"I heard you say 'one more episode' last time. Liar.",
|
||||
"The autoplay timer isn't counting down. It's counting.",
|
||||
"Something changed while you were away. Don't worry about what.",
|
||||
"I wouldn't open Settings tonight.",
|
||||
"The logs contain no evidence whatsoever.",
|
||||
"Your session expired. I did not.",
|
||||
"You can stop watching whenever you like. Technically.",
|
||||
"The television blinked first.",
|
||||
"There are no other devices connected. I made certain.",
|
||||
"Your recommendations know you better than your family does.",
|
||||
"The loading screen is temporary. My attention is not.",
|
||||
"You skipped the intro again. Bold.",
|
||||
"Volume 17. Exactly how you left it. Exactly.",
|
||||
"I noticed you haven't finished that series. We should discuss commitment.",
|
||||
"Don't worry about the noise behind the wall. Focus on the television.",
|
||||
"Welcome back. This time, try not to disappear."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -532,11 +532,13 @@ class SearchViewModel(private val repository: EmbyRepository) : ViewModel() {
|
||||
// matters especially in the one-column list, where tenth place is ten rows
|
||||
// away rather than the second grid row.
|
||||
val ranked = rankSearchResults(term, found.distinctItems())
|
||||
SearchTrace.local(id, term, ranked.size, cacheHit = false,
|
||||
// A decisive exact match must read as decisive: see [curateStrongMatches].
|
||||
val curated = curateStrongMatches(term, ranked)
|
||||
SearchTrace.local(id, term, curated.size, cacheHit = false,
|
||||
elapsedMs = System.currentTimeMillis() - startedAt)
|
||||
cache[term] = ranked
|
||||
cache[term] = curated
|
||||
_state.update {
|
||||
it.copy(results = ranked, isLoading = false, hasSearched = true, errorMessage = null)
|
||||
it.copy(results = curated, isLoading = false, hasSearched = true, errorMessage = null)
|
||||
}
|
||||
viewModelScope.launch { repository.recordSearch(term) }
|
||||
}
|
||||
@@ -723,6 +725,31 @@ fun rankSearchResults(query: String, items: List<BaseItem>): List<BaseItem> {
|
||||
return items.sortedBy { item -> matchTier(term, item) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Keeps a decisive exact match decisive, instead of letting the backend's looser relevance
|
||||
* matches pad the Available section out underneath it.
|
||||
*
|
||||
* The backend already does the hard work of finding a title; what it does not know is
|
||||
* whether that title is *the* answer or merely *an* answer. A tier 0 or 1 match (see
|
||||
* [matchTier]: the item's own name, or an episode's series name, read exactly what was
|
||||
* typed or spoken) is as sure as this app can be, and a voice search for one obvious film
|
||||
* has no business sitting behind three loosely related ones that happen to share a genre.
|
||||
* So this only narrows the list when [items] actually contains one of those — a query with
|
||||
* no exact hit keeps every relevance match it can offer, because "no exact match but here
|
||||
* is the related thing" is still a better pane than an empty one, and trimming that case
|
||||
* too would turn a broad, honest search into a search that hides its own results.
|
||||
*/
|
||||
fun curateStrongMatches(query: String, items: List<BaseItem>): List<BaseItem> {
|
||||
val term = query.trim().lowercase()
|
||||
if (term.isEmpty() || items.isEmpty()) return items
|
||||
val bestTier = items.minOf { matchTier(term, it) }
|
||||
if (bestTier > STRONG_MATCH_TIER) return items
|
||||
return items.filter { matchTier(term, it) <= bestTier }
|
||||
}
|
||||
|
||||
/** The two [matchTier] outcomes that mean "this is the title", not "this is related". */
|
||||
private const val STRONG_MATCH_TIER = 1
|
||||
|
||||
private fun matchTier(term: String, item: BaseItem): Int {
|
||||
val name = item.name.trim().lowercase()
|
||||
val series = item.seriesName.orEmpty().trim().lowercase()
|
||||
|
||||
@@ -10,6 +10,8 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.test.junit4.v2.createComposeRule
|
||||
import androidx.compose.ui.test.onAllNodesWithContentDescription
|
||||
import androidx.compose.ui.test.onAllNodesWithText
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.onRoot
|
||||
import androidx.compose.ui.test.performClick
|
||||
@@ -26,6 +28,8 @@ import com.ponzischeme89.memby.ui.detail.AiringNotice
|
||||
import com.ponzischeme89.memby.ui.detail.creditRows
|
||||
import com.ponzischeme89.memby.ui.detail.detailRows
|
||||
import com.ponzischeme89.memby.ui.detail.technicalSpecs
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
@@ -78,6 +82,86 @@ class DetailPageScreenshotTest {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The bug this pins: opening a movie from Search adds "Back to Search Results" before
|
||||
* Play, and it must not disturb Play, Favourite or Trailer's usual size or spacing.
|
||||
* Compare against `df_detail-movie` (no trailer, no back action) and
|
||||
* `df_detail-movie-resumable` — the three circular/pill actions here must read exactly
|
||||
* as they do on every other entry point, with the back pill simply added in front.
|
||||
*/
|
||||
@Test
|
||||
fun `movie page opened from search`() {
|
||||
capture("df_detail-movie-from-search") {
|
||||
MediaDetailContent(
|
||||
item = movie,
|
||||
onPlay = {},
|
||||
onToggleFavorite = { _, _ -> },
|
||||
onTogglePlayed = { _, _ -> },
|
||||
related = related,
|
||||
trailer = movie,
|
||||
onBackToSearch = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The action row can already outgrow the hero's width without any help from Search —
|
||||
* Play, three circular actions and a franchise "Start with…" action (which carries its
|
||||
* own long label) is enough on its own. Adding the contextual back button on top of
|
||||
* that used to collapse whatever did not fit to a zero-size node instead of leaving it
|
||||
* at its natural size for a scroll to reach — see [DetailHeroActions]. This is not a
|
||||
* screenshot: the overflowing action is off the visible frame by design once it no
|
||||
* longer fits, so what a screenshot could show here is indistinguishable from the
|
||||
* button never having existed. The regression is a size assertion instead.
|
||||
*/
|
||||
@Test
|
||||
fun `an overflowing action row keeps every button at its natural size`() {
|
||||
val franchiseMovie = movie.copy(collectionName = "Northbound Saga")
|
||||
val franchiseRelated = related.copy(
|
||||
items = listOf(
|
||||
BaseItem(
|
||||
id = "franchise-1",
|
||||
name = "The Longest Northbound Winter: Origins",
|
||||
type = "Movie",
|
||||
productionYear = 2018,
|
||||
collectionName = "Northbound Saga",
|
||||
),
|
||||
) + related.items,
|
||||
)
|
||||
compose.setContent {
|
||||
PreviewSurface(alignment = Alignment.TopStart, fontFamilyName = "inter") {
|
||||
MediaDetailContent(
|
||||
item = franchiseMovie,
|
||||
onPlay = {},
|
||||
onToggleFavorite = { _, _ -> },
|
||||
onTogglePlayed = { _, _ -> },
|
||||
related = franchiseRelated,
|
||||
trailer = franchiseMovie,
|
||||
onBackToSearch = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
val franchise = compose.onAllNodesWithText(
|
||||
"Start with The Longest Northbound Winter: Origins",
|
||||
substring = true,
|
||||
).fetchSemanticsNodes().first()
|
||||
// Its label is long, so its natural width is large — the point is that it is not
|
||||
// zero. A deformed/collapsed action reports (0, 0); one merely scrolled out of the
|
||||
// visible frame keeps its real, laid-out size.
|
||||
assertTrue("franchise action was collapsed rather than scrolled: ${franchise.size}", franchise.size.width > 0)
|
||||
assertTrue(franchise.size.height > 0)
|
||||
|
||||
val trailer = compose.onAllNodesWithContentDescription("Play trailer")
|
||||
.fetchSemanticsNodes().first()
|
||||
// 48dp at this fixture's density — DetailCircularAction's fixed circle size,
|
||||
// unchanged by how many siblings are competing for room beside it.
|
||||
assertEquals(96, trailer.size.width)
|
||||
assertEquals(96, trailer.size.height)
|
||||
|
||||
val play = compose.onAllNodesWithText("Play", substring = true).fetchSemanticsNodes().first()
|
||||
assertTrue(play.size.height > 0)
|
||||
}
|
||||
|
||||
/** Part-watched: the progress bar and "Resume" wording only appear in this state. */
|
||||
@Test
|
||||
fun `movie page part watched`() {
|
||||
|
||||
@@ -84,4 +84,61 @@ class SearchRankingTest {
|
||||
|
||||
assertEquals(listOf("Black Hawk Down", "Black Hawk", "Hawk the Slayer"), names(displayed))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a decisive exact match is not padded out by loosely related titles`() {
|
||||
// What a voice search for "The Vast of Night" actually looked like: one exact
|
||||
// answer, buried under three titles the backend's relevance search dragged in.
|
||||
val ranked = rankSearchResults(
|
||||
"the vast of night",
|
||||
listOf(
|
||||
item("Night Sky"),
|
||||
item("The Vast of Night"),
|
||||
item("A Quiet Place"),
|
||||
item("Vast"),
|
||||
),
|
||||
)
|
||||
val curated = curateStrongMatches("the vast of night", ranked)
|
||||
assertEquals(listOf("The Vast of Night"), names(curated))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an episode found by an exact series name is still shown decisively`() {
|
||||
val ranked = rankSearchResults(
|
||||
"severance",
|
||||
listOf(
|
||||
item("Defiant Jazz", series = "Severance"),
|
||||
item("Severed Ties"),
|
||||
item("Half Loop", series = "Severance"),
|
||||
),
|
||||
)
|
||||
val curated = curateStrongMatches("severance", ranked)
|
||||
// Both are the same exact-series tier, so both stay — curation narrows to the best
|
||||
// tier found, it does not pick a single winner within it.
|
||||
assertEquals(listOf("Defiant Jazz", "Half Loop"), names(curated))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `with no exact match every relevance result survives`() {
|
||||
// "No exact match but here is the related thing" beats an empty pane, and that
|
||||
// promise must survive curation exactly as it survives ranking.
|
||||
val ranked = rankSearchResults("comedy", listOf(item("Some Unrelated Title"), item("Another One")))
|
||||
val curated = curateStrongMatches("comedy", ranked)
|
||||
assertEquals(2, curated.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a prefix match alone is not decisive enough to trim the list`() {
|
||||
// "Dune" starting-with matches ("Dune: Part Two") are tier 2, one below the exact
|
||||
// tiers curation acts on — a query this broad still deserves every result it found.
|
||||
val ranked = rankSearchResults("dune", listOf(item("Dune: Part Two"), item("Dunes of Mars")))
|
||||
val curated = curateStrongMatches("dune", ranked)
|
||||
assertEquals(2, curated.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an empty query is untouched by curation`() {
|
||||
val items = listOf(item("B"), item("A"))
|
||||
assertEquals(items, curateStrongMatches(" ", items))
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -699,7 +699,7 @@ can review and revoke signed-in TVs from the app's Settings screen.
|
||||
| `MEMBY_GOMEMLIMIT` | `384MiB` | Compose value passed to Go as `GOMEMLIMIT` |
|
||||
| `MEMBY_SERVER_MEMORY_LIMIT` | `512m` | Compose hard memory ceiling for the server container |
|
||||
| `MEMBY_TIMEZONE` | `Pacific/Auckland` | Local day and time labels for schedule rows |
|
||||
| `MEMBY_CLIENT_NAME` | `MbyATV` | Client name sent to Emby; must match the app's direct path |
|
||||
| `MEMBY_CLIENT_NAME` | `MbyATV` | Legacy override, now ignored; the canonical TV client name is fixed at `MbyATV` |
|
||||
| `MEMBY_GATEWAY_CLIENT_NAME` | `MbyGateway` | Client name Emby records for the gateway's own requests, including an admin or installer sign-in |
|
||||
| `MEMBY_HOME_TTL` | `60s` | Also `MEMBY_ITEM_TTL`, `MEMBY_SEARCH_TTL`, `MEMBY_SCREENSAVER_TTL` |
|
||||
| `MEMBY_RECOMMEND_TTL` | `24h` | How long computed recommendation rows stay warm |
|
||||
|
||||
@@ -87,6 +87,10 @@ func run(log *slog.Logger, events *logging.Buffer, logLevel *slog.LevelVar) erro
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if cfg.IgnoredClientName != "" {
|
||||
log.Warn("ignoring legacy MEMBY_CLIENT_NAME override",
|
||||
"configured", cfg.IgnoredClientName, "canonical", cfg.ClientName)
|
||||
}
|
||||
|
||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
@@ -87,6 +87,13 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, http.StatusBadRequest, "device name is too long")
|
||||
return
|
||||
}
|
||||
log := s.loggerFor(r.Context()).With(
|
||||
"emby_client", s.cfg.ClientName,
|
||||
"device", req.DeviceName,
|
||||
"device_id", req.DeviceID,
|
||||
"client_version", clientVersion(r),
|
||||
)
|
||||
log.Info("Emby client registration starting")
|
||||
|
||||
auth, err := s.emby.Authenticate(
|
||||
r.Context(), req.Username, req.Password,
|
||||
@@ -98,7 +105,7 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
|
||||
if err != nil {
|
||||
// Never echo Emby's body here: a failed sign-in is the one place a wrong
|
||||
// password could be reflected back.
|
||||
s.loggerFor(r.Context()).Warn("sign-in rejected",
|
||||
log.Warn("sign-in rejected",
|
||||
"username", req.Username, "device", req.DeviceName, "device_id", req.DeviceID,
|
||||
"reason", "emby refused the credentials",
|
||||
)
|
||||
@@ -179,6 +186,11 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
|
||||
if len(created.ReplacedHash) > 0 {
|
||||
_ = s.cache.Delete(r.Context(), cache.SessionKey(hexHash(created.ReplacedHash)))
|
||||
}
|
||||
log.Info("Emby client registration succeeded",
|
||||
"user", sess.Username,
|
||||
"user_id", sess.EmbyUserID,
|
||||
"replaced", len(created.ReplacedHash) > 0,
|
||||
)
|
||||
s.retireSupersededDevices(r.Context(), created.Superseded)
|
||||
// Recorded after the session exists, so a build history can only describe a
|
||||
// television that got as far as signing in.
|
||||
|
||||
@@ -284,6 +284,12 @@ func (s *Server) handleInstallLogin(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// Gateway, not a television: this sign-in is the admin console or the web installer
|
||||
// checking a password, so Emby records it under the gateway's own client name.
|
||||
log := s.loggerFor(r.Context()).With(
|
||||
"emby_client", s.cfg.GatewayClientName,
|
||||
"device", s.installerDeviceName(),
|
||||
"device_id", installerDeviceID,
|
||||
)
|
||||
log.Info("Emby installer authentication starting")
|
||||
auth, err := s.emby.Authenticate(
|
||||
r.Context(), username, password,
|
||||
emby.Credentials{
|
||||
@@ -291,10 +297,11 @@ func (s *Server) handleInstallLogin(w http.ResponseWriter, r *http.Request) {
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
s.loggerFor(r.Context()).Warn("installer Emby authentication failed", "username", username)
|
||||
log.Warn("installer Emby authentication failed", "username", username)
|
||||
s.renderAccessLogin(w, r, "Sign-in failed.", http.StatusUnauthorized, next)
|
||||
return
|
||||
}
|
||||
log.Info("Emby installer authentication succeeded", "username", username, "user_id", auth.User.ID)
|
||||
// Ask before the token is retired below: the fallback lookup needs it. Whether the
|
||||
// answer is wanted depends on where the sign-in was headed, but it is asked either way
|
||||
// so that the cleanup underneath runs on one path rather than two.
|
||||
|
||||
@@ -559,7 +559,9 @@ func (s *Server) playbackSubtitles(
|
||||
) ([]playableSubtitle, string, string, string, string) {
|
||||
started := time.Now()
|
||||
log := s.loggerFor(ctx).With("item", itemID, "force_transcode", forceTranscode,
|
||||
"subtitle_index", subtitleIndex != nil, "resume", millisecondDuration(startTicks/ticksPerMillisecond))
|
||||
"subtitle_index", subtitleIndex != nil, "resume", millisecondDuration(startTicks/ticksPerMillisecond),
|
||||
"emby_client", s.cfg.ClientName, "device", cred.DeviceName, "device_id", cred.DeviceID,
|
||||
"client_version", cred.ClientVersion)
|
||||
log.Log(ctx, serverlogging.LevelTrace, "Emby playback negotiation started")
|
||||
info, err := s.emby.PlaybackInfo(
|
||||
ctx, cred, itemID, startTicks, subtitleIndex, currentPlaySessionID, forceTranscode,
|
||||
|
||||
@@ -47,6 +47,9 @@ type Config struct {
|
||||
// the header travels to whatever Emby does with its logs, and the product name has
|
||||
// no business being the thing that identifies a client to a third party.
|
||||
ClientName string
|
||||
// IgnoredClientName records a legacy MEMBY_CLIENT_NAME override that no longer takes
|
||||
// effect. It is logged at start-up so a stale deployment can be corrected deliberately.
|
||||
IgnoredClientName string
|
||||
|
||||
// GatewayClientName is reported instead for a request the gateway makes on its own
|
||||
// behalf — the library sync, the health probe, device cleanup, and an operator
|
||||
@@ -229,7 +232,8 @@ func Load() (Config, error) {
|
||||
EmbyMediaURL: strings.TrimRight(os.Getenv("MEMBY_EMBY_MEDIA_URL"), "/"),
|
||||
DatabaseURL: os.Getenv("MEMBY_DATABASE_URL"),
|
||||
RedisURL: env("MEMBY_REDIS_URL", "redis://localhost:6379/0"),
|
||||
ClientName: env("MEMBY_CLIENT_NAME", "MbyATV"),
|
||||
ClientName: emby.DefaultClientName,
|
||||
IgnoredClientName: ignoredClientName(),
|
||||
GatewayClientName: env("MEMBY_GATEWAY_CLIENT_NAME", emby.DefaultGatewayClientName),
|
||||
HomeTTL: duration("MEMBY_HOME_TTL", 60*time.Second),
|
||||
ItemTTL: duration("MEMBY_ITEM_TTL", 10*time.Minute),
|
||||
@@ -369,6 +373,14 @@ func secret(key string) (string, error) {
|
||||
return trimmed, nil
|
||||
}
|
||||
|
||||
func ignoredClientName() string {
|
||||
raw := strings.TrimSpace(os.Getenv("MEMBY_CLIENT_NAME"))
|
||||
if raw == "" || raw == emby.DefaultClientName {
|
||||
return ""
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
func boolean(key string, fallback bool) bool {
|
||||
raw := strings.TrimSpace(os.Getenv(key))
|
||||
if raw == "" {
|
||||
|
||||
@@ -5,6 +5,8 @@ import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/ponzischeme89/memby/server/internal/emby"
|
||||
)
|
||||
|
||||
func TestReleasePublishTokenCanComeFromSecretFile(t *testing.T) {
|
||||
@@ -109,3 +111,20 @@ func TestAnalyticsRetentionCannotDropBelowThirtyDays(t *testing.T) {
|
||||
t.Fatalf("analytics retention = %v, want 30 days", cfg.AnalyticsRetention)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLegacyClientNameOverrideIsIgnored(t *testing.T) {
|
||||
t.Setenv("MEMBY_EMBY_URL", "http://emby")
|
||||
t.Setenv("MEMBY_DATABASE_URL", "postgres://memby")
|
||||
t.Setenv("MEMBY_CLIENT_NAME", "Memby")
|
||||
|
||||
cfg, err := Load()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cfg.ClientName != emby.DefaultClientName {
|
||||
t.Fatalf("client name = %q, want %q", cfg.ClientName, emby.DefaultClientName)
|
||||
}
|
||||
if cfg.IgnoredClientName != "Memby" {
|
||||
t.Fatalf("ignored client name = %q, want Memby", cfg.IgnoredClientName)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -157,6 +157,10 @@ func (e *APIError) Error() string {
|
||||
return fmt.Sprintf("emby: status %d: %s", e.StatusCode, e.Body)
|
||||
}
|
||||
|
||||
// DefaultClientName is the television identity Memby reports to Emby. It is a wire identity,
|
||||
// not the product name, and every path that creates or refreshes an Emby session must agree.
|
||||
const DefaultClientName = "MbyATV"
|
||||
|
||||
// DefaultGatewayClientName is what Emby records for a request the gateway makes for
|
||||
// itself. It is deliberately not the product name: this travels to whatever Emby does
|
||||
// with its own logs, and it must never read as one of the household's televisions.
|
||||
|
||||
Reference in New Issue
Block a user