| Item | Notes |
|---|---|
Remove StallRecoveryVideoRenderer.kt |
Unwired, marked TODO: remove; safe to delete once Fire TV regression testing confirms MPD correction covers all stall scenarios |
- Analyzed all Kodi plugin source files in
/home/vscode/kodi-plugin/plugin.video.amazon-test/resources/lib/ - Mapped all API endpoints: token refresh, GetPlaybackResources (manifest + license), catalog browsing (Android API), watchlist, search, stream reporting
- Documented auth flow: token-based with Bearer header, refresh via POST to
api.{domain}/auth/token - Documented device fingerprint: SHIELD Android TV identity (A43PXU4ZN2AL1), User-Agent, app metadata
- Documented Widevine license flow: custom challenge/response wrapping (widevine2Challenge → widevine2License.license)
- Output written to
analysis/api-map.mdandanalysis/decisions.md
- Created Android project scaffold at
/home/vscode/amazon-vod-android/app/ - Kotlin, minSdk 25, targetSdk 34
- Dependencies: Media3 ExoPlayer + DASH, OkHttp, Gson, Coroutines, Coil
- Activities: MainActivity (browse/search) → BrowseActivity (detail) → PlayerActivity
- Gradle wrapper (8.6), version catalog (libs.versions.toml)
AmazonAuthService.kt— loads token from.device-token, OkHttp interceptor (auth + android headers), token refresh on 401AmazonApiService.kt— catalog browsing (home/search/watchlist/library/detail), GetPlaybackResources manifest fetch, UpdateStream reportingAmazonLicenseService.kt— custom MediaDrmCallback: wraps challenge aswidevine2Challenge=<base64url>, parseswidevine2License.licenseJSON response
PlayerActivity.kt— Media3 ExoPlayer with DASH + DefaultDrmSessionManager (Widevine)- Custom
AmazonLicenseServicewired asMediaDrmCallback - Stream reporting (START/PLAY/STOP) via
UpdateStream - Release keystore generated at
/home/vscode/amazon-vod-android/release.keystore
assembleReleaseBUILD SUCCESSFUL, APK signed with release keystoreadb install -r app-release.apk— Success- Token file pushed to
/data/local/tmp/.device-token(world-readable, avoids external storage permission) - App launches, territory detected (DE → atv-ps-eu.amazon.de / A1PA6795UKMFR9)
Bugs fixed during debug loop:
- Catalog 404: changed POST → GET for catalog calls
- German account: added
detectTerritory()via GetAppStartupConfig - JSON parsing: unwrap
resourcewrapper, iteratecollectionsas JsonArray - Widevine provisioning — 3-part fix:
a.
executeProvisionRequest: use plain OkHttpClient (no Amazon auth headers sent to Google) b. Google API requires POST with JSON body{"signedRequest":"..."}, not GET c. Device reboot needed soamzn_drmprovlinks new Widevine cert to Amazon account - License denial (
PRSWidevine2LicenseDeniedException): added missing params tobuildLicenseUrl(deviceVideoQualityOverride=HD,deviceVideoCodecOverride=H264,deviceHdrFormatsOverride=None)
Result: Video plays with Widevine L1 HW secure decode
secure.HW.video.avcdecoder active at up to 5830 kbpssecureSW.SW.audio.rawdecoder active at 640 kbps- Adaptive bitrate streaming working
- Screenshot shows black (expected:
FLAG_SECUREblocks captures of DRM content)
Extended search and browse to cover all available content categories:
isPrime— parsed from model.isPrime / primeOnly / badgeInfoisFreeWithAds— parsed from model.isFreeWithAds / freeWithAds / badgeInfoisLive— parsed from contentType=="live" or liveInfo/liveState metadata fieldschannelId— parsed from playbackAction.channelId / station.id
ContentCategoryenum: ALL, PRIME, FREEVEE, CHANNELS, LIVEgetCategoryContent(category, query)— unified entry point; routes to correct API + client-side filtergetChannelsPage()— browse with channels pageId; falls back to find page filtered by channelIdgetSearchSuggestions(query)— debounced; returns first 8 titles from search (no dedicated Amazon suggestions endpoint found in Kodi plugin)parseContentItems()updated to extract all new metadata fields
EditTextreplaced withAutoCompleteTextView— shows suggestion dropdown as user types- 300ms debounce on keystroke calls
getSearchSuggestions(); results populate dropdown - Category filter row added: All | Prime | Freevee | Channels | Live
- Active category highlighted in blue (#00A8E0); inactive grey (#555)
- Category selection calls
getCategoryContent()with current search query - Nav buttons (Home/Watchlist/Library) reset category to ALL
Symptom: Search bar visible but user cannot type — keyboard never appeared or couldn't be dismissed. Root causes & fixes:
AutoCompleteTextViewreplaced with customDpadEditText(extendsAppCompatEditText) — suggestion dropdown interfered with Fire TV IMEDpadEditText.onKeyPreIme()intercepts BACK key before the IME consumes it —SHOW_FORCEDkeyboard now dismisses properly on back pressstateHiddenadded to manifestwindowSoftInputMode— prevents keyboard auto-showing on activity start- Keyboard shows only on explicit DPAD_CENTER click via
setOnClickListener+showSoftInput(SHOW_FORCED) dismissKeyboardAndSearch()hides keyboard, clears focus to RecyclerView, then triggers search
Files changed: DpadEditText.kt (new), MainActivity.kt, activity_main.xml, AndroidManifest.xml
Symptom: Search API called successfully but parser returned 0 items. Root causes & fixes:
- Search response uses
titles[0].collectionItemList— parser only handledcollections[].collectionItemList(home/browse format). Addedtitlesarray parsing branch. getAsJsonPrimitive()throwsClassCastExceptionon JSON null values. AddedsafeString()/safeBoolean()extension methods onJsonObjectthat return null for both missing and null fields.- Search model uses
idfield (nottitleId) for ASIN. Addedidas fallback in ASIN extraction chain.
Files changed: AmazonApiService.kt
Symptom: Only first row (from collections format) showed images; search results had blank posters.
Root cause: Search model stores images in titleImageUrls object (BOX_ART, COVER, POSTER keys) and heroImageUrl, not image.url.
Fix: Added image extraction chain: image.url → imageUrl → titleImageUrls.{BOX_ART,COVER,POSTER,LEGACY,WIDE} → heroImageUrl → titleImageUrl → imagePack
Also fixed ContentAdapter to clear images on recycled ViewHolders.
Files changed: AmazonApiService.kt, ContentAdapter.kt
Symptom: "Prime Video" app title bar visible at top during video playback. Root causes & fixes:
- PlayerActivity used default
Theme.FireTVwhich inheritsTheme.AppCompat(includes ActionBar). Switched toTheme.FireTV.Playerwith parentTheme.AppCompat.NoActionBar. - Added immersive sticky mode in
onCreateandonResume:SYSTEM_UI_FLAG_IMMERSIVE_STICKY | FULLSCREEN | HIDE_NAVIGATION - Explicit
supportActionBar?.hide()call.
Files changed: PlayerActivity.kt, AndroidManifest.xml, themes.xml
- Long-press to toggle watchlist: Any content item can be added/removed from watchlist via long-press on the grid
- Visual watchlist indicator: Star icon overlay on each content card (filled = in watchlist, outline = not in watchlist)
- Startup watchlist sync: App fetches all watchlist ASINs on launch to mark items correctly
- Optimistic UI: Watchlist state updates immediately in the adapter after API confirms success
- Toast feedback: User sees "Adding to / Removing from / Added to / Removed from watchlist" messages
addToWatchlist(asin)— callsAddTitleToListendpointremoveFromWatchlist(asin)— callsRemoveTitleFromListendpointgetWatchlistAsins()— fetches watchlist page, extracts ASIN set
- Added
isInWatchlist: Boolean = falsefield
item_content.xml— poster wrapped in FrameLayout; added star icon overlay (iv_watchlist) at top-rightContentAdapter.kt— addedonItemLongClickcallback,watchlistIconbinding, star on/off based onisInWatchlistMainActivity.kt— addedwatchlistAsinscache,toggleWatchlist()method, startup fetch,showItems()marks itemsBrowseActivity.kt— updatedContentAdapterconstructor call for new named parameter
- 20 watchlist items loaded on startup
- 74 home content items displayed with correct watchlist indicators
- Library sub-filters: All / Movies / TV Shows filter chips shown when Library nav is active
- Sort toggle: Cycles through Recent / A→Z / Z→A on each click
- Pagination: Infinite scroll — loads next page via
libraryNext/v2.jswithstartIndexwhen user scrolls near bottom - Nav button highlight: Active nav button (Home/Watchlist/Library) highlighted in blue (#00A8E0)
- Library-specific UI: Library filter row shown only on Library page; category filter chips hidden
- Empty library message: Shows "Your library is empty. Rent or buy titles to see them here." when no purchased/rented content
- Added
LibraryFilterenum: ALL, MOVIES, TV_SHOWS - Added
LibrarySortenum: DATE_ADDED, TITLE_AZ, TITLE_ZA - Added
getLibraryPage(startIndex, filter, sort)— paginated, filtered, sorted library fetch - Initial page:
libraryInitial/v2.js; subsequent pages:libraryNext/v2.jswithstartIndexparam - Client-side filtering by contentType (Feature/Movie for Movies, Episode/Season/Series for TV Shows)
- Client-side sorting for title A-Z / Z-A; API default for date added
activity_main.xml:- Added
library_filter_rowwithbtn_lib_all,btn_lib_movies,btn_lib_shows,btn_lib_sort - Added
idtocategory_filter_rowfor visibility toggling - Added
nextFocusDownon search field →btn_home
- Added
MainActivity.kt:- Added
currentNavPagetracking +updateNavButtonHighlight()+updateFilterRowVisibility() - Added library state:
libraryFilter,librarySort,libraryNextIndex,libraryLoading - Added
setLibraryFilter(),cycleLibrarySort(),loadLibraryInitial(),loadLibraryNextPage() - Added RecyclerView scroll listener for infinite scroll pagination
loadNav("library")resets filter/sort and callsloadLibraryInitial()
- Added
- Response:
{"resource":{"pageTitle":"Video Library", "refineModel":{"filters":[...],"sorts":[...]}, "titles":[...], "dataWidgetModels":[...]}} - Filters from API: TV Shows / Movies / Pay-Per-View
- Sorts from API: Most Recent Addition / Title A-Z / Title Z-A
- Empty library returns
"titles":[]anddataWidgetModelswith"textType":"EMPTY_CUSTOMER_LIST"
- Library endpoint called successfully
- Library sub-filter row (All/Movies/TV Shows/Sort) visible when Library active
- Category filter row hidden when Library active
- Empty library message displayed correctly (account has no purchased content)
- Nav button highlight working (Library button highlighted in blue)
- Freevee nav button added: New "Freevee" button between Home and Watchlist in the nav row
- Channels & Live removed: Removed
btn_cat_channels,btn_cat_live,btn_cat_freeveecategory chips from layout;ContentCategoryenum simplified from{ALL, PRIME, FREEVEE, CHANNELS, LIVE}to{ALL, PRIME}; removedgetChannelsPage()method
getFreeveePage()— tries catalog Browse endpoint withOfferGroups=B0043YVHMY(Kodi: common.py:186) for server-side Freevee filtering- Catalog Browse uses Kodi device TypeIDs (
A3SSWQ04XYPXBH,A1S15DUFSI8AUG,A1FYY15VCM5WG1) withmessage.body.titles[]response format - Falls back to home page content when catalog Browse is unavailable (404 on global US endpoint)
parseCatalogBrowseItems()— parses Kodi-style catalog response format
- Territory detection now fully functional — see "Territory Detection Fix" section below
- Freevee (Amazon's free ad-supported service) is not available in all territories (e.g. DE)
activity_main.xml: Addedbtn_freeveenav button; removed Channels/Live/Freevee category chips; category row now only has All + PrimeMainActivity.kt: AddedbtnFreeveebinding and click handler; updatedupdateNavButtonHighlight()andupdateFilterRowVisibility()for freevee page; both filter rows hidden for freevee
- Build:
assembleReleaseSUCCESS - Deploy: APK installed on Fire TV
- Home: 74 items displayed
- Freevee: Falls back to 74 home items (catalog Browse returns 404 in DE territory)
- Watchlist: 20 items displayed
- Library: 0 items (no purchases on account)
- All nav buttons functional with correct highlight
- Added
MOVIESandSERIEStoContentCategoryenum - Added companion object helpers:
isMovieContentType(),isSeriesContentType(),isEpisodeContentType(),isPlayableType() getCategoryContent()updated to filter by content type (MOVIE/Feature for Movies; Season/Series/Show for Series)- Home page content types: ~49 MOVIE + ~25 SEASON items
- Selecting a SEASON item from any page → opens
BrowseActivitywith series detail - Detail page API:
android/atf/v3.jstlwithitemId=param → returns{show, seasons, episodes, selectedSeason} - Parser updated to extract items from
seasons[]andepisodes[]arrays in detail response - Season items:
[titleId, title, seasonNumber, badges, aliases]— formatted as "Season N" - Episode items:
[id, linkAction, title, episodeNumber, contentType, ...]— formatted as "EN: Title" - Multi-season shows: seasons list → select season → episodes list → select episode → play
- Single-season shows: episodes shown directly (skip season selection)
- Episode items from detail page have
titleIdonly insidelinkActionobject (not at top level) - Added
linkAction.titleIdto ASIN extraction chain:catalogId → compactGti → titleId → linkAction.titleId → id → asin
- GTI-format ASINs (
amzn1.dv.gti.*) rejectvideoMaterialType=EpisodewithPRSInvalidRequestException videoMaterialType=Featureworks for both movies AND episodes with GTI ASINs- Changed PlayerActivity to always use
FeaturematerialType
activity_main.xml: Added Movies/Series filter buttons to category row with proper D-pad navigation (nextFocusDown)activity_browse.xml: AddeddescendantFocusability="afterDescendants"for D-pad focusMainActivity.kt: Added Movies/Series button bindings, series routing to BrowseActivity, grid focus managementBrowseActivity.kt: Complete series drill-down with filter logic, grid focus after item load
- Build:
assembleReleaseSUCCESS - Movies filter: Shows ~49 MOVIE type items from home page
- Series filter: Shows ~25 SEASON type items from home page
- Series drill-down: "Wake Season 1" → detail page → 7 items (seasons + episodes)
- Episode playback: "E1: Wide Awake" plays with 1920x1080 HW secure decode, 2.5-5 Mbps video
- D-pad navigation: Grid items focusable, first child auto-focused after load
Implemented full watch progress tracking per dev/analysis/watch-progress-api.md:
- UpdateStream (legacy GET) — enhanced with
titleId,timecodeChangeTime(ISO 8601),userWatchSessionId; parsescallbackIntervalInSecondsfrom response - PES V2 (modern POST) —
pesStartSession(),pesUpdateSession(),pesStopSession()at/cdp/playback/pes/; ISO 8601 duration format (PT1H23M45S); session token management secondsToIsoDuration()helper for PES V2 timecode format
startStreamReporting()— sends UpdateStream START + PES StartSession on first STATE_READYstartHeartbeat()— periodic PLAY events at server-directed interval (min of UpdateStream and PES intervals)sendProgressEvent()— dual-API calls (UpdateStream + PES UpdateSession) with interval refreshstopStreamReporting()— sends STOP to both APIs on STATE_ENDED, onStop(), or player error- PAUSE support via
onIsPlayingChangedlistener — pauses heartbeat, sends PAUSE event - Server-directed heartbeat interval via
callbackIntervalInSecondsresponse field
- PES V2 StartSession returns HTTP 400 — requires
playbackEnvelope(encrypted playback authorization from Amazon's playback infrastructure) which is not available via GetPlaybackResources - PES V2 methods are implemented but non-functional without the envelope; falls back gracefully
- UpdateStream alone is sufficient for "Continue Watching" / resume position syncing
- Build:
assembleReleaseSUCCESS - Deploy: APK installed on Fire TV Stick 4K
- Widevine L1 HW secure playback active (secureSW.SW.audio.raw ~640kbps)
- UpdateStream START:
SUCCESS,canStream: true,statusCallbackIntervalSeconds: 180 - UpdateStream PLAY heartbeat: fires every 60s,
SUCCESS, position tracks correctly (t=59s) - UpdateStream PAUSE: fires on HOME key press,
SUCCESS(t=16s) - UpdateStream STOP: fires from
onStop()lifecycle,SUCCESS(t=16s) - Server-directed interval (
statusCallbackIntervalSeconds: 180) parsed and applied
extractSubtitleTracks()— parsessubtitleUrls[]andforcedNarratives[]from GetPlaybackResources response- Returns list of
SubtitleTrack(url, languageCode, type)where type is "regular", "sdh", or "forced" - Already requesting
desiredResources=PlaybackUrls,SubtitleUrls,ForcedNarrativesandaudioTrackId=all
- Added
SubtitleTrackdata class (url, languageCode, type) - Added
subtitleTracksfield toPlaybackInfo
DefaultTrackSelector— replaces ExoPlayer's implicit selector; enables programmatic track overrides- External subtitle tracks loaded via
SingleSampleMediaSource+MergingMediaSource(TTML format) —SubtitleConfigurationonMediaItemis ignored byDashMediaSource - Audio tracks automatically available from DASH manifest (ExoPlayer parses MPD Adaptation Sets)
showTrackSelectionDialog(trackType)— builds AlertDialog listing available audio or text tracks- Audio: shows language + channel layout (5.1, Stereo, etc.)
- Subtitles: shows language + type label (SDH, Forced); includes "Off" option
- Applies selection via
TrackSelectionOverrideontrackSelectionParameters
- Audio/Subtitle buttons shown at top-right when playback starts (STATE_READY)
- Added
track_buttonsLinearLayout at top-right withbtn_audioandbtn_subtitlebuttons - Semi-transparent black background, white text, D-pad focusable
- Build:
assembleReleaseSUCCESS - Deploy: APK installed on Fire TV Stick 4K
- Video plays with Widevine L1 (audio codec active ~640kbps)
- Track buttons visible during playback, D-pad navigable
- No crashes on button press or track selection
- Full Amazon OAuth login flow: email + password → MFA (optional) → device registration → token save
- PKCE challenge: SHA-256 code verifier/challenge for OAuth security
- OAuth sign-in: POST to
api.amazon.com/ap/signinwith OpenID 2.0 + OAuth 2.0 extension params - MFA support: Detects when Amazon returns MFA challenge; shows OTP input field; resubmits with
otpCode/mfaResponse - Device registration: POST to
api.amazon.com/auth/registerwith authorization_code + code_verifier + device fingerprint - Token persistence: Saves TokenData (access_token, refresh_token, device_id, expires_at) to
/data/local/tmp/.device-token - Auto-skip: If valid token file already exists, skips login and launches MainActivity directly
- Skip button: "Use Device Token" fallback for development/debugging when .device-token is pre-pushed via ADB
- Email field (textEmailAddress input)
- Password field (textPassword input)
- MFA container (hidden initially, shown when 2FA required)
- Sign In button (blue #00A8E0)
- Status text (errors in red, info in blue, success in green)
- Progress spinner during network calls
- "Use Device Token" skip button (visible only when token file exists)
LoginActivityis now the LAUNCHER activity (entry point)MainActivitychanged toexported="false"(launched from LoginActivity after auth)- Login flow: LoginActivity → (check token / perform login) → MainActivity
.device-tokenfile continues to work exactly as before for debugging- Existing devices with pre-pushed tokens skip login automatically
- New devices show login screen, register via OAuth, then proceed to browse
- Triggers: push to main, pull requests to main, manual workflow_dispatch
- Build environment: Ubuntu latest, JDK 17 (Temurin), Android SDK via android-actions/setup-android
- Date-based versioning:
YYYY.MM.DD_Nformat (e.g.,2026.02.26_1), auto-increments N per day based on existing git tags - Signing: Decodes
release.keystorefrom GitHub Secret (base64), configures signing via environment variables - APK output: Renamed to
FireOS-AVOD-{version}.apkand uploaded as artifact (90-day retention) - Auto-release: On push to main, creates a GitHub Release with tag
v{version}and attaches signed APK
versionNamereads from-PversionNameOverrideGradle property (default:1.0-dev)versionCodereads from-PversionCodeOverrideGradle property (default:1)- Signing config reads keystore path/passwords from environment variables with local fallbacks
- Local development continues to work unchanged (env vars fall back to hardcoded dev values)
RELEASE_KEYSTORE_BASE64— base64-encoded release.keystoreRELEASE_STORE_PASSWORD— keystore passwordRELEASE_KEY_ALIAS— key aliasRELEASE_KEY_PASSWORD— key password
- Added all new features (login, track selection, watch progress, resume, CI/CD)
- Updated architecture diagram with LoginActivity
- Added CI/CD section with secrets table and versioning explanation
- Updated deploy instructions with new LoginActivity entry point
- Added authentication section explaining both in-app login and dev token workflows
deviceTypeIDmismatch:GetAppStartupConfigwas using Kodi's defaultA28RQHJKHM2A2Wwhile our device was registered withA43PXU4ZN2AL1. API returnedCDP.Authorization: Device type id in request does not match.supportedLocales=en_US— only sent one locale; Kodi sends 18 locales. Amazon requires the user's locale to be listed to return territory info- No
sidomain— token refresh was hardcoded toapi.amazon.com. DE accounts needapi.amazon.de homeRegionparsed from wrong parent — was looking interritoryConfig, actually undercustomerConfig- Invalid
uxLocale— API can return error strings likeLDS_ILLEGAL_ARGUMENTinstead of a valid locale
AmazonApiService.kt:TerritoryInfodata class replacesPair<String, String>— addssidomainandlangTERRITORY_MAPexpanded with 8 entries includingA2MFUE2XK8ZSSY(PV EU Alt)detectTerritory()rewritten with 3-layer detection (Kodilogin.py:55-78)buildDynamicTerritory()helper for unknown marketplaces (constructs URL fromdefaultVideoWebsite+homeRegion)supportedLocalessends 18 localesuxLocalevalidated with regex[a-z]{2}_[A-Z]{2}deviceTypeIDusesAmazonAuthService.DEVICE_TYPE_IDinstead of hardcoded Kodi value
AmazonAuthService.kt:- Removed hardcoded
REFRESH_ENDPOINTconstant - Added
siDomainfield +setSiDomain()setter - Token refresh uses
https://api.$siDomain/auth/token
- Removed hardcoded
- Territory:
atvUrl=https://atv-ps-eu.amazon.de marketplace=A1PA6795UKMFR9 sidomain=amazon.de lang=de_DE - Catalog: 20 watchlist + 74 home items loaded from DE endpoint with
de_DElocale - No errors in logcat
- Problem:
DashMediaSourceignoresMediaItem.SubtitleConfiguration— external subtitles were added to theMediaItembut never loaded by the player - Fix: Use
SingleSampleMediaSourcefor each subtitle track, merged with DASH source viaMergingMediaSource - Verified: 2 TTML subtitle tracks extracted and loaded,
TtmlParserconfirming parsing in logcat
- Problem:
getWatchlistPage()only loadedwatchlistInitial(first ~20 items) - Root cause: Used
getDataByTransform(JS transforms) which only supports initial page;watchlistNext/v3.jsreturns HTTP 500;watchlistInitialignoresstartIndexparam causing infinite loop - Fix: Switched to
getDataByJvmTransform(Kotlin switchblade transforms) matching Prime Video 3.0.438 decompilation:- Initial:
dv-android/watchlist/initial/v1.ktwithpageType=watchlist&pageId=Watchlist - Next:
dv-android/watchlist/next/v1.ktwithserviceToken+pageSize=20frompaginationModel.parameters
- Initial:
- Added
extractPaginationParams()helper that replays all pagination keys from previous response - Added root-level
collectionItemListparsing inparseContentItems()(next-page response format) - Added
loadWatchlistInitial()/loadWatchlistNextPage()inMainActivitywith infinite scroll getWatchlistAsins()now loads all pages with stall detection and max-page safety limit- Verified: 113 watchlist items loaded across 6 pages (20+20+20+20+20+13), no duplicates
- Problem: Home page showed duplicate movies from overlapping collections
- Fix: Added
distinctBy { it.asin }inparseContentItems()— reduced home from 74 to 57 items
- Problem: All content pages displayed in API return order (unsorted)
- Fix:
showItems()now sorts all items bytitle.lowercase()before submitting to adapter - Applies to home, search, watchlist, and freevee pages
Replaced the flat alphabetical content grid on the Home tab with categorized horizontal carousels matching the structure returned by the Amazon v2 landing API.
model/ContentRail.kt—data class ContentRail(headerText, items, collectionId, paginationParams)ui/RailsAdapter.kt— outer verticalListAdapter<ContentRail>with sharedRecycledViewPool; each row inflatesitem_rail.xmland wires an innerContentAdapterwithLinearLayoutManager(HORIZONTAL)res/layout/item_rail.xml—LinearLayout(vertical)containingTextViewheader + horizontalRecyclerView
PRIME_SERVICE_TOKENconstant —eyJ0eXBlIjoibGliIiwibmF2IjpmYWxzZSwiZmlsdGVyIjp7Ik9GRkVSX0ZJTFRFUiI6WyJQUklNRSJdfX0=getHomePageRails(paginationParams)— hitsdv-android/landing/initial/v2.kt(first call) ordv-android/landing/next/v2.kt(pagination); returnsPair<List<ContentRail>, String>(rails + nextPageParams); falls back to v1 flat list if v2 failsparseRails(json)— iteratescollections[], extractsheaderTextper collection, callsparseItemsFromArray()percollectionItemList; extractspaginationModelfor page-level paginationparseItemsFromArray(JsonArray)— extracted fromparseContentItems()as shared helper; used by both the flat list and rails parsersgetWatchlistData()— returnsPair<Set<String>, Map<String, Pair<Long,Long>>>(ASINs + progress map); used to merge watch progress from watchlist data (which hasremainingTimeInSeconds) into rail items (which do not)- Watch progress parsing —
remainingTimeInSecondssemantics:remainSec > 0 && remainSec < runtimeSec→ PARTIAL →watchProgressMs = runtimeMs - remainSec*1000remainSec >= runtimeSec→ not started →watchProgressMs = 0remainSec == 0→ ambiguous ("no data") →watchProgressMs = 0- timecode fallback:
timecodeSecondsused if available (detail API format)
isRailsModeflag +switchToRailsMode()/switchToGridMode()— swapslayoutManagerbetweenLinearLayoutManager(VERTICAL)andGridLayoutManager(5)and swaps adapterloadHomeRails()— fetches v2 rails on IO dispatcher, then callsshowRails()loadHomeRailsNextPage()— infinite scroll: triggers when last visible rail is within 3 of total, appends new rails;homePageLoadingguard prevents duplicate requestsshowRails(rails)— merges watchlist membership,watchlistProgress(server-side progress from watchlist data), and local resume positions into each rail's items before submitting torailsAdapterloadNav("home")— now callsloadHomeRails()instead ofloadFilteredContent(); all other tabs callswitchToGridMode()first- Filter row — hidden on Home (server curates carousels); visible on Watchlist; Movies/Series filter chips apply to rails via
applyTypeFilterToRails() - Search — switching to search on Home calls
switchToGridMode(); clearing search returns to rails onResume()— refreshes watch progress within rails when returning from player (rails mode)watchlistProgressfield —Map<String, Pair<Long, Long>>loaded alongsidewatchlistAsinsat startup viagetWatchlistData()
- Progress bar rendering — switched from custom
@drawable/watch_progress_bar(didn't render in nested RecyclerView) to@android:style/Widget.ProgressBar.HorizontalwithprogressTintList/progressBackgroundTintListset programmatically - Progress bar height increased from 5dp to 8dp for visibility
- v2 rails API does not include
remainingTimeInSecondsin its item data — watch progress only available from watchlist API - v1 home landing returns
remainingTimeInSeconds=0for items that have real progress in the watchlist API (ambiguous — treat as "no data") remainingTimeInSecondsreflects time remaining, not time watched —watchProgressMs = runtimeMs - remainingSec*1000- Custom XML
progressDrawabledoes not render in nested RecyclerView-in-RecyclerView context; must use default style +progressTintList
- Build:
assembleReleaseSUCCESS - Home tab: vertically stacked horizontal-scrolling carousels with section headers (Featured, Derzeit beliebt, Täglich neue Filme, etc.)
- D-pad: left/right scrolls within a rail, up/down moves between rails
- Scroll down: more rails load via page-level pagination (4 initial + 19 + 6 = 29 total rails)
- Amber progress bars on "Der Tiger" (~27%) and "The Life of Chuck" (~26%) confirmed visible in "Täglich neue Filme" rail
- Movies/Series filter chips apply to rails (filter items by contentType within each rail)
- Watchlist/Library/Freevee/Search tabs use flat grid (unchanged)
showItems() — used by search results, Freevee, Library, and all flat grid views — only merged
local SharedPreferences resume positions into items (resumeMap[it.asin] ?: it.watchProgressMs).
It never consulted watchlistProgress (the server-side remainingTimeInSeconds map built from the
watchlist API at startup). Result: items like "The Tank" correctly showed their watchlist star but
displayed no amber progress bar in search results, even though they had partial watch history.
showRails() (home carousels) already had the correct three-way merge; only showItems() was missing it.
Applied the same three-way merge that showRails() uses:
progressMs = localResumePos ?: watchlistProgress[asin]?.first ?: item.watchProgressMs
runtimeMs = if (watchlistProgress[asin] != null && item.runtimeMs == 0L)
watchlistProgress[asin]!!.second
else item.runtimeMs
watchlistProgress contains only items where both watchProgressMs > 0 and runtimeMs > 0
after parsing the watchlist API response. Items with a star but no progress bar in the grid are
legitimately unwatched watchlist bookmarks — this is correct behaviour.
showItems()now shows amber progress bars for partially-watched items in search resultsshowRails()was already correct; home rail items with progress continue to show bars- Watchlist screenshot updated:
screenshots/04_watchlist.pngshows The Tank with amber bar
- ⚙ gear button pushed to the far right of the nav bar via a weighted spacer View
- About screen shows: app version (from
PackageManager), package name, masked device ID (first 8 + last 4 chars), token file location (internal storage vs legacy/data/local/tmp) - Sign Out button (red, with confirmation dialog): deletes internal token, sets
logged_out_attimestamp inauthSharedPreferences, clearsresume_positions, starts LoginActivity withFLAG_ACTIVITY_CLEAR_TASK
ui/AboutActivity.kt— new; reads token viaLoginActivity.findTokenFile(), shows info, performs logoutres/layout/activity_about.xml— ScrollView with APP and ACCOUNT sections, red Sign Out buttonactivity_main.xml— added weighted spacer +btn_aboutgear button at right end of nav rowMainActivity.kt— addedbtnAboutfield, click →startActivity(AboutActivity)AndroidManifest.xml— registeredAboutActivity(not exported)
File("/data/local/tmp/.device-token").delete() silently returns false — the app process lacks
write permission on /data/local/tmp (directory owned by shell). Without a guard, LoginActivity's
findTokenFile() finds the surviving legacy file and immediately bounces back to MainActivity.
performLogout()storeslogged_out_at = System.currentTimeMillis()inauthSharedPreferencesfindTokenFile()compares legacy file'slastModified()againstlogged_out_at:- File older than logout → skip (stale token) → return null → login screen
- File newer than logout → accept (fresh debug token pushed after logout) → clear flag → auto-login
launchMain()clearslogged_out_aton real login
adb push preserves the host file's mtime, so a bare push of a 2026-02-26 file appears older than
a 2026-02-28 logout. Developer must run adb shell touch after push:
adb push .device-token /data/local/tmp/.device-token
adb shell touch /data/local/tmp/.device-token- ⚙ button visible top-right on all nav pages
- About screen shows correct version (2026.02.28.x), masked device ID, token location
- Sign Out → confirmation dialog → LoginActivity shown, does NOT bounce back
- Push + touch fresh token after logout → cold restart auto-logins
- Tags: v2026.02.28.2 (initial) … v2026.02.28.4 (timestamp fix)
After signing out and trying to log back in via the login form, Amazon returned "Please Enable Cookies to Continue" (2950-byte response) on the credential POST — every time, not intermittently.
Amazon serves two different login page modes depending on request headers:
- Browser mode (default): requires JavaScript-set cookies for CSRF validation → fails in plain HTTP client
- App mode (
X-Requested-With: com.amazon.avod.thirdpartyclient+x-gasc-enabled: true): accepts the credential POST without JS cookies
The register_device.py reference script sets these headers globally on its requests.Session.
The LoginActivity OkHttp client was only setting User-Agent and was not sending X-Requested-With
or x-gasc-enabled, so Amazon served the browser-mode page whose form submission requires cookies
that only JavaScript can set.
Additional fixes in the same commit:
- Added
Origin: https://www.amazon.comto the credential POST (browsers send this on form submit) - Aligned
Accept-Languagevalue (en-US,en;q=0.9) across all login requests
Added an OkHttp application interceptor to the login httpClient that appends the two headers to
every request in the login flow (homepage, sign-in link, OAuth URL, credential POST):
.addInterceptor { chain ->
val req = chain.request().newBuilder()
.header("X-Requested-With", AmazonAuthService.APP_NAME)
.header("x-gasc-enabled", "true")
.build()
chain.proceed(req)
}- In-app login now works: email + password → CVF/MFA (if required) → MainActivity
- Sign Out → re-login via form → success
- Tag: v2026.02.28.5
Full codebase review performed by an AI agent using dev/REVIEW.md as the checklist. Findings documented in dev/review-findings.md. All actionable warnings fixed.
- 0 Critical, 10 Warnings, 0 Info, 47 OK across 53 checklist items
- Commit:
d1bcc07— "fix: apply code review findings F-002 through F-010"
| Finding | Description | Result |
|---|---|---|
| F-001 | Catalog GET vs POST | FALSE POSITIVE — POST returns 404; GET is correct |
| F-002 | Password not cleared after login | Fixed — etPassword.setText("") before launchMain() |
| F-003 | x-gasc-enabled in API client |
Fixed — removed from AndroidHeadersInterceptor |
| F-004 | PlayerActivity scope not cancelled | Fixed — named scopeJob, cancel in onDestroy(), guard in setupPlayer() |
| F-005 | CI keystore not deleted | Fixed — rm -f release.keystore with if: always() |
| F-006 | versionCode not monotonic within a day | Fixed — derived from full YYYY.MM.DD.N version string |
| F-007 | Password trimmed before submission | Fixed — removed .trim() from password field |
| F-008 | LoginActivity scope not cancelled | Fixed — named scopeJob, added onDestroy() |
| F-009 | showItems() forced A-Z sort |
Fixed — removed .sortedBy { it.title.lowercase() } |
| F-010 | onStop() calls player?.stop() |
Fixed — changed to player?.pause() |
Symptom: The AUDIO and SUBTITLES buttons at top-right were permanently visible once playback started (STATE_READY set View.VISIBLE and nothing ever hid them again).
Fix (PlayerActivity.kt):
- Removed
trackButtons.visibility = View.VISIBLEfrom theSTATE_READYhandler - Added
showOverlay(autoHide)/hideOverlay()helpers usingView.postDelayed+removeCallbacks onIsPlayingChanged(true)→hideOverlay()(clean screen during playback)onIsPlayingChanged(false)+ STATE_READY →showOverlay(autoHide = false)(stay visible while paused)onKeyDown(KEYCODE_MENU)→ toggles overlay; auto-hides after 3 s if currently playingonDestroy→removeCallbacks(hideOverlayRunnable)to prevent leaks
Verified on Fire TV: Overlay hidden during L1 playback (clean black screen); visible after pause (AUDIO + SUBTITLES at top-right, seekbar + play button); hidden again on resume.
Commit: 9b12f75
Symptom: When the overlay appeared (on MENU press during play), the AUDIO/SUBTITLES buttons showed but could not receive D-pad focus because the PlayerView controller was hidden — the two overlay regions were out of sync.
Root cause: trackButtons was managed independently of PlayerView's built-in controller. When showOverlay() made trackButtons visible during playback, the player controller remained hidden; without a visible focusable anchor in the controller area, D-pad navigation could not reach the track buttons either.
Fix (PlayerActivity.kt):
- Added
playerView.setControllerVisibilityListener→trackButtons.visibility = visibilityto keep both in sync at all times - MENU key now calls
playerView.showController()/playerView.hideController()instead of managingtrackButtonsdirectly — the listener propagates the change automatically - Removed manual
showOverlay/hideOverlaymethods and thehideOverlayRunnable/OVERLAY_TIMEOUT_MSconstants —PlayerViewowns the auto-hide timing onIsPlayingChangedstream-reporting logic unchanged; overlay management removed from it entirely
Behaviour after fix:
- Pause →
PlayerViewkeeps controller visible → listener setstrackButtonsVISIBLE → both fully focusable via D-pad - Resume →
PlayerViewauto-hides controller after its timeout → listener setstrackButtonsGONE - MENU (during play or pause) → toggles controller → listener toggles track buttons
Verified on Fire TV. Build: 2026.02.28.8
Symptom: The Audio dialog listed the same language (e.g. "German (Stereo)") 3-5 times.
Root cause: Amazon's DASH manifests use one AdaptationSet per bitrate variant (each as a separate ExoPlayer TrackGroup), rather than multiple Representation elements within one set. The old dialog iterated every track within every group, producing one entry per bitrate per language.
Fix (PlayerActivity.kt — showTrackSelectionDialog):
- One entry per group (bitrate variants within a group are ExoPlayer's ABR responsibility)
- Representative format: currently-playing track, else highest-bitrate track in the group
- Codec qualifier (
· Dolby/· AAC) added only when two groups share the same base label (same language + channel count but different codecs) - Final label deduplication: when multiple per-bitrate groups produce the same final label, keep the highest-bitrate group (or currently-selected group if any)
- Selection uses
TrackSelectionOverride(group, emptyList())— adaptive within the chosen group
Symptom: Left/right D-pad on the seekbar jumped ~6 minutes per press on a 2-hour film.
Root cause: DefaultTimeBar default key increment = duration ÷ 20. For a 7200 s film that is 360 s (6 min).
Fix (PlayerActivity.kt — onCreate):
playerView.findViewById<DefaultTimeBar>(R.id.exo_progress)
?.setKeyTimeIncrement(10_000L) // 10 s per D-pad pressVerified on Fire TV Stick 4K: both fixes confirmed working (2026.02.28.12).
Long-press to toggle watchlist is awkward on a TV remote. Replaced with a MENU key (hamburger button) context menu that appears on any focused content card.
- Added
holder.itemView.tag = iteminonBindViewHolderso Activity-level code can retrieve theContentItemfrom any focused view - Replaced
onItemLongClickcallback withonMenuKey(used both for the item-level key listener and the Activity-level handler) setOnKeyListenerkept as a secondary path (works in some RecyclerView configurations)
- Renamed
onItemLongClick→onMenuKey, threads through to innerContentAdapter
showItemMenu(item): showsAlertDialogtitled with the item name; single list item "Add to Watchlist" / "Remove from Watchlist"toggleWatchlist(item): now updates both the flat adapter ANDunfilteredRails+railsAdapter(previously only updated the flat adapter, leaving home carousels stale after a toggle)onKeyDown(KEYCODE_MENU): callsfocusedContentItem()and shows menu — handles cases whereKEYCODE_MENUis consumed by the Activity before reaching the view'ssetOnKeyListenerfocusedContentItem(): callsrecyclerView.findFocus()then walks UP the parent chain looking for a view tagged withContentItem— works for both flat grid (direct child) and nested rails (inner RecyclerView child at arbitrary depth)onItemSelected(): passeswatchlistAsinstoBrowseActivityviaEXTRA_WATCHLIST_ASINSStringArrayList extra
- Added
EXTRA_WATCHLIST_ASINSconstant +watchlistAsins: MutableSet<String>field - Extracts
watchlistAsinsfrom Intent inonCreate; wiresonMenuKeycallback on the adapter loadDetails()appliesisInWatchlistflag to all displayed items after loadshowItemMenu()/toggleWatchlist(): same AlertDialog + API call pattern as MainActivity; updates local adapter andwatchlistAsinsonItemSelected()(child BrowseActivity for season → episodes drill-down): passes updatedwatchlistAsinsforward via the same extra
View.setOnKeyListener for KEYCODE_MENU is unreliable — Android's PhoneWindow intercepts KEYCODE_MENU at the window level before key events reach the focused view in some RecyclerView configurations. The robust fix is Activity.onKeyDown(KEYCODE_MENU) combined with recyclerView.findFocus() + tag-based item lookup, which always fires regardless of the focused view type.
The Alexa Voice Remote shipped with Fire TV Stick 4K (AFTR/raven) has no physical Menu button — KEYCODE_MENU (82) is never generated. The MENU-only trigger was therefore silently inert on this device.
Fix: restored setOnLongClickListener on item views in ContentAdapter as the primary trigger (hold D-pad SELECT ~500 ms → AlertDialog). KEYCODE_MENU retained as secondary for older/3rd-party remotes that do have the button.
- Emulator: MENU key → AlertDialog on all three contexts (home rails, flat grid, BrowseActivity)
- Fire TV Stick 4K: long press SELECT → AlertDialog; "Add/Remove from Watchlist" works end-to-end
Full visual overhaul of every screen — from functional prototype to a polished, TV-first streaming
experience. Commit: 26eeec6 — "Redesign TV UI" (84 files, +4885 / -1042 lines).
- Rounded card surfaces with elevation shadow and gradient overlay; title always readable
- Animated focus ring + glow:
card_focus_glow.xml+card_selector.xml; scale animation on focus - Four card variants: portrait (
item_content.xml), landscape (item_content_landscape.xml), episode (item_content_episode.xml), season (item_content_season.xml) - Watch-progress card (
item_content_progress.xml) — amber progress bar overlaid on card
- Persistent top nav bar with icon buttons (Home, Search, Watchlist, Library, Freevee, About);
active tab highlighted with
nav_active_indicator.xml; D-pad focus chain wired between nav and content - Page transition animations — fade+slide in/out (
page_enter/exit/pop_enter/pop_exit.xml) - Filter chip row redesigned with pill-shaped
filter_chip_background.xml
- Shimmer skeleton (
ShimmerAdapter.kt+item_shimmer_card.xml) — shown while API calls are in flight; replaced on completion without flicker
- Hero backdrop redesigned with gradient scrim; metadata badges (4K, HDR, 5.1) via
UiMetadataFormatter.kt; consistent typography hierarchy - Browse screen (
activity_browse.xml) — full-bleed header background, panel-style grid
- Semi-transparent gradient overlay (
player_overlay_bg.xml,player_overlay_panel.xml); pill-shaped track buttons (player_track_button.xml,player_track_label.xml); error panel
- Action overlay dialog (
WatchlistActionOverlay.kt+dialog_watchlist_action.xml) — replaces plain AlertDialog with a styled bottom-sheet-style confirmation
ContentItemParser.kt— extracted fromAmazonApiService.kt; testable in isolation; covered byContentItemParserTest.ktUiMetadataFormatter.kt— centralises badge/chip label formatting; covered byUiMetadataFormatterTest.ktUiMotion.kt—revealFresh()entry animation helper used on all screensUiTransitions.kt— shared page transition helpers- Color palette (
colors.xml): unified token set; consistent surface/on-surface/accent colours - Dimension tokens (
dimens.xml): card sizes, rail spacing, badge padding integers.xml: animation duration constants
A dedicated overview screen (DetailActivity) inserted before playback for every content item.
Endpoint used: android/atf/v3.jstl via getDataByTransform/v1 — the same endpoint already used by getDetailPage(). No new endpoint needed.
Key fields discovered:
resource.synopsis— full description text ✓resource.detailPageHeroImageUrl— 16:9 backdrop image ✓resource.imdbRating(float) +resource.imdbRatingCount(int) ✓resource.genres[]— array (filter entries containing>which are sub-genres) ✓resource.releaseDate— Unix ms timestamp → extract year ✓resource.runtimeSeconds✓resource.amazonMaturityRating(e.g. "13+") ✓resource.directors[]✓resource.isTrailerAvailable(boolean) ✓resource.badges.{uhd, hdr, dolby51, prime}✓resource.isInWatchlist✓- For SEASON ASINs: data lives in
resource.selectedSeason.*, notresource.*directly
Trailer: GetPlaybackResources?asin={GTI_ASIN}&videoMaterialType=Trailer works with the same content ASIN — confirmed returning a valid DASH manifest.
dv-android/detail/v2/user/v2.5.js: returns HTTP 500 for our account/territory — not used.
Cast: Not available in android/atf/v3.jstl. Future work if needed.
See dev/analysis/detail-page-api.md for full documentation.
ui/DetailActivity.kt— overview screen with: hero image, poster, title, year/runtime/age rating, IMDb rating, genres, synopsis, directors, action buttonsres/layout/activity_detail.xml— FrameLayout root with hero (220dp) + info row (poster + text panel)res/drawable/hero_gradient.xml— bottom-to-top gradient overlay for hero sectionmodel/DetailInfo.kt— data class for all detail fieldsdev/analysis/detail-page-api.md— full API documentation
api/AmazonApiService.kt— addedgetDetailInfo(asin)+parseDetailInfo()usingandroid/atf/v3.jstlui/PlayerActivity.kt— addedEXTRA_MATERIAL_TYPE(default"Feature"); caller can pass"Trailer"for trailer playbackui/MainActivity.kt—onItemSelected()now routes ALL items (movies + series) toDetailActivityui/BrowseActivity.kt— season selection now routes toDetailActivityinstead of nestedBrowseActivityAndroidManifest.xml—DetailActivityregistered
Home / Watchlist card (movie) → DetailActivity → [▶ Play] → PlayerActivity
→ [▶ Trailer] → PlayerActivity (Trailer)
Home / Watchlist card (series) → DetailActivity → [Browse Seasons] → BrowseActivity → episodes → play
BrowseActivity (seasons list) → season card → DetailActivity → [Browse Episodes] → BrowseActivity (episodes)
BrowseActivity (episodes list) → episode card → PlayerActivity (unchanged, direct play)
android/atf/v3.jstlfor MOVIE: all metadata directly inresource.*android/atf/v3.jstlfor SEASON: metadata inresource.selectedSeason.*;resource.showhas series titleId/title- GTI-format ASINs work with
videoMaterialType=FeatureANDvideoMaterialType=Trailer isTrailerAvailable: trueis reliable — confirmed by actual trailer manifest fetch- Sub-genre strings like
"Thriller > Mystery"must be filtered fromgenres[]
Bug: Action buttons (Play, Trailer, Browse, Watchlist) could be cut off when synopsis text was long — they were in a plain wrap_content LinearLayout below the scrollable metadata.
Fix: Restructured activity_detail.xml — hero image (200dp) at top, info row (poster | NestedScrollView) in the middle with layout_weight=1, and a fixed bottom bar (wrap_content) containing all action buttons. The bottom bar is always rendered regardless of how much text appears above it.
Feature: Season detail pages now show two action buttons: "Browse Episodes" (opens episode list for the current season) and "All Seasons" (opens the season picker for the parent show). Uses resource.show.titleId (stored as DetailInfo.showAsin) which the API already returns for season ASINs. The button only appears when showAsin is non-empty.
Problem: Some titles return HTTP 400 on H265 init segments (CDN rejects the request for that codec).
Fix: onPlayerError in PlayerActivity detects ERROR_CODE_IO_BAD_HTTP_STATUS when currentQuality.codecOverride contains "H265" and !h265FallbackAttempted. Saves resume position, releases player, re-fetches with PlaybackQuality.HD (H264 manifest). h265FallbackAttempted flag prevents infinite retry.
Problem: H265 fallback was calling GetAppStartupConfig again on retry because territoryDetected was set but never read.
Fix: Added if (territoryDetected) return at top of detectTerritory(). Eliminates redundant network call on fallback.
Feature: Added tv_video_format TextView to the top-right overlay in activity_player.xml. Label shows "720p · H265 · SDR" or "4K · H265 · HDR10" etc., updated via updateVideoFormatLabel().
Implementation: Reads player.videoFormat (live decoder format, not the track group seed). Called from onVideoSizeChanged, onTracksChanged, and STATE_READY. Codec from sampleMimeType (hevc → H265, avc → H264). HDR from colorInfo.colorTransfer or codec string prefix (hvc1.2.* = HDR10).
Attempt: Tried setPreferredVideoMimeType(VIDEO_H264) + re-prepare for faster switching without full manifest re-fetch.
Reverted: UHD manifest only contains H264 up to 720p, so track selector fallback yielded 720p H264 from the UHD manifest. Re-fetch with PlaybackQuality.HD is correct — it fetches the HD-specific manifest that provides full-tier H264.
Finding: Amazon's deviceVideoQualityOverride=HD caps at 720p SDR for both H264 and H265. There is no 1080p SDR tier. 1080p+ requires UHD quality + HDR format override.
- HEVC Main profile (
hvc1.1.*) = SDR H265, available at HD tier, observed at 720p (or occasionally 800p per CDN encoding) - HEVC Main 10 (
hvc1.2.*) = HDR10, requiresUHD + Hdr10quality params
Final quality presets (PlaybackQuality.kt):
| Preset | quality | codecOverride | hdrOverride | Result |
|---|---|---|---|---|
| HD (H264) | HD | H264 | None | 720p H264 SDR |
| H265 | HD | H264,H265 | None | 720p H265 SDR (no HDR needed) |
| 4K / DV HDR | UHD | H264,H265 | Hdr10,DolbyVision | 4K H265 HDR (requires HDR display) |
Documented in dev/analysis/decisions.md Decision 16.
Problem: Selecting 4K/DV HDR on an SDR TV causes a blank screen (device requests HDR stream, display can't render it).
Fix: displaySupportsHdr() checks windowManager.defaultDisplay.hdrCapabilities.supportedHdrTypes. If empty, resolveQuality() falls back UHD_HDR → HD (H264). AboutActivity.setupQualitySection() disables 4K/DV button when display has no HDR and shows an explanatory note. Combined capability line: "Device H265/HEVC: Yes · Display HDR: Yes (HDR10)".
Problem: After H265 fallback, the format label retained the H265 value from the previous attempt.
Fix: tvVideoFormat.text = "" at the top of loadAndPlay().
Problem: Trailer ASIN = movie ASIN; playing a trailer to completion wrote resumePrefs[asin] = -1 (watched marker), marking the movie as fully watched.
Fix: saveResumePosition() returns early if currentMaterialType == "Trailer". STATE_ENDED handler skips resumePrefs write for trailers. currentMaterialType field set in loadAndPlay().
Problem: setupPlayer() read resumePrefs.getLong(currentAsin, 0L) — trailers share the movie ASIN and inherited the movie's resume position.
Fix: val resumeMs = if (currentMaterialType == "Trailer") 0L else resumePrefs.getLong(currentAsin, 0L).
The home tab shows horizontal content rails. The existing Prime / All source filter chips
(btn_cat_all, btn_cat_prime) are wired only to the flat-grid view (Watchlist, Search,
Library). When the Home tab is active the category_filter_row is hidden and the filter has no
effect on rails content.
Users want to be able to filter home rails to Prime-only content, matching the behaviour they already get on the Watchlist tab.
| Screen | Filter behaviour today | Target behaviour |
|---|---|---|
| Home (rails) | No filter visible | Prime / All chips visible, filter items within each rail |
| Watchlist / Library / Freevee / Search (flat grid) | Prime / All chips work | Unchanged |
Show filter row on Home tab: In MainActivity.kt, unhide category_filter_row when the
Home tab is selected (currently it is only shown on non-Home tabs).
Filter rail items client-side: After loadHomeRails() fills the RailsAdapter, re-apply
the current source filter (activeSourceFilter = "all" or "prime") to each rail's item list.
Keep the full unfiltered list in a backing field (allRailsData) so toggling the chip does not
require a network re-fetch.
A rail whose items are all filtered out should be hidden entirely (set rail visibility to GONE) rather than showing an empty horizontal strip.
Type filter (Movies / Series): Same approach — apply the activeTypeFilter to rail items.
This is already supported on flat-grid via ContentItem.contentType; the same field is available
on rail items.
Filter field on ContentItem: The isPrime flag is already parsed from the catalog response
(check ContentItem.kt and the rail parser in AmazonApiService.kt). If the field is absent or
not yet parsed, add it during this phase.
State to preserve: The filter selection must survive tab switches. activeSourceFilter and
activeTypeFilter are already tracked in MainActivity — no new state needed.
| File | Change |
|---|---|
ui/MainActivity.kt |
Show category_filter_row on Home tab; apply filter to allRailsData on chip click |
ui/RailsAdapter.kt |
Expose a submitFilteredData(rails) method; hide empty rails |
model/ContentItem.kt |
Confirm isPrime: Boolean field exists; add if missing |
api/AmazonApiService.kt |
Confirm rail item parser populates isPrime; add if missing |
MainActivity.kt:category_filter_rowis now shown on the Home tab as well as flat-grid tabs. The full unfiltered rail data is stored inallRailsData; applying a filter callsapplyRailFilters()which rebuilds each rail's item list client-side, hiding empty rails.RailsAdapter.kt: addedsubmitFilteredData(rails)that sets visibilityGONEon rails with no items after filtering; outer rail header is also hidden.ContentItem.kt/AmazonApiService.kt:isPrimeandisFreeWithAdsalready populated — no change needed.
Hero / Featured carousel items use a different JSON schema — they carry Prime entitlement via
messagePresentationModel.entitlementMessageSlotCompact[].imageId == "ENTITLED_ICON" with
slot text containing "prime". The ENTITLED_ICON alone is not sufficient: it also appears for
channel subscription content (e.g., Paramount+). The text field must contain "prime" to
distinguish genuine Prime inclusion.
Fix in ContentItemParser.kt: hasEntitledIcon now requires both imageId == "ENTITLED_ICON" and text.contains("prime", ignoreCase = true). This allowed Featured
rail items like F-Valentine's Day and Wilhelm Tell to pass the Prime filter correctly.
The custom track_buttons overlay originally used ControllerVisibilityListener with its own
animation + timeout logic, which drifted out of sync with the Media3 controller — especially when
overlay buttons had focus and the controller auto-hide timer fired, causing flicker and the overlay
lingering after controls hid.
Fix: the overlay now follows the actual controller visibility rather than managing its own lifetime.
controllerViewis resolved once viaplayerView.findViewById(exo_controller).syncTrackButtonsRunnablepolls every 120 ms while the controller is shown, keepingtrackButtonsvisible only while the real controller view is visible.hideTrackButtonsRunnableruns immediately when the controller hides — no animation delay, no alpha-reset flash.- Forced
controllerShowTimeoutMs = 0/ focus-listener approach was removed in favour of this simpler poll-based sync, which handles all edge cases (focus, dialog dismiss, back-press). - MENU key still explicitly toggles controls; auto-focus on
btnAudiowas changed to apostDelayed(120 ms)guard so it only fires once the overlay is confirmed visible. - Native
exo_subtitleandexo_settingsbuttons are hidden on startup and re-hidden after everyonTracksChanged(they re-appear on track change).
The original code built the audio menu entirely from live ExoPlayer track metadata, which on Fire TV is unreliable: blank labels, repeated language groups, repeated bitrate blocks, no stable index. This caused:
- Audio Description (AD) tracks selected by default on some titles.
- AD tracks disappearing from the menu after an auto-switch attempt.
- Track labels showing language codes instead of human-readable names.
Fix: audio metadata is now sourced primarily from Amazon's own APIs, merged with live player data for selection.
AmazonApiServiceparses audio track metadata fromGetPlaybackResources(playback audio tracks) and the detail API, includingdisplayName,languageCode,type(dialog / descriptive / dialogue-boost), andindex.PlayerActivitymerges both sources and logs them (Merged audio metadata: ...).- Audio families are normalised as:
main,ad,boost-medium,boost-high. - The menu is built by mapping live ExoPlayer groups onto metadata families, keeping the best candidate (by selection status, then bitrate) per family.
- AD labelling is metadata-first: if Amazon says
type=descriptive, the track is labelled AD regardless of weak ExoPlayer role flags. normalizeInitialAudioSelection()replaces the oldautoTrackSelectionDoneauto-switch — it runs on firstonTracksChanged, switches away from AD if selected, and never fires again.- Language base-code matching (
devsde-de) ensures live Fire TV tracks can be matched to API metadata correctly. - Channel layout suffixes (
2.0,5.1,7.1) are appended when ExoPlayer exposeschannelCount. - Dialogue Boost entries are filtered out of the menu by default.
DefaultTrackSelectoris configured withsetPreferredAudioRoleFlags(C.ROLE_FLAG_MAIN)to bias initial selection toward main-dialogue tracks.
player.setPlaybackSpeed() has no effect on Fire TV: Amazon's EMP (Extras Media Player) system
service intercepts it via a hidden MediaSession proxy and resets speed to 1.0× every ~80 ms for
DRM content. Feature omitted.
| File | Change |
|---|---|
ui/PlayerActivity.kt |
Overlay sync via syncTrackButtonsRunnable; audio metadata merge; buildAudioTrackOptions using Amazon metadata families; normalizeInitialAudioSelection; channel-layout suffix; native button suppression |
api/AmazonApiService.kt |
Parse and expose audio track metadata from playback + detail APIs |
res/layout/activity_player.xml |
setShowSubtitleButton(false) enforced in code |
Playback audio tracks asin=...Detail audio tracks asin=...Merged audio metadata: ...Live audio tracks: ...Audio menu options: ...
Two separate issues discovered after Phase 24:
-
showPrimeEmblemis unreliable — Amazon sets this field totrueon all catalog season items, including seasons available only via channel subscriptions (e.g., The Handmaid's Tale Season 6 via Paramount+). Using it as the Prime indicator for the detail page produced false positives. -
No Prime status shown on detail page — There was no way to quickly verify whether a title is included with Prime from the detail screen; users had to infer it from the home filter.
DetailInfo.isPrime: Boolean = false— new field added to the model.AmazonApiService.parseDetailInfo()— parsesbadges.primefrom the ATF v3 detail endpoint response. This field is authoritative: it reflects actual Prime inclusion for the requesting account and territory, not just content tagging. Example: The Handmaid's Tale Season 5 returnsbadges.prime = truein Germany (it IS included with Prime); Season 6 returnsfalse(it requires a Paramount+ subscription).DetailActivity.bindDetail()— usesinfo.isPrime(from the detail API) rather than the catalog-levelisItemPrimeintent extra that came fromshowPrimeEmblem.activity_detail.xml— addedtv_prime_badgeTextView in the right content panel (after IMDb rating). Shows"✓ Included with Prime"in teal or"✗ Not included with Prime"in grey; always visible when the detail page loads.
| File | Change |
|---|---|
model/DetailInfo.kt |
Added isPrime: Boolean = false |
api/AmazonApiService.kt |
Parse badges.prime in parseDetailInfo(); pass to DetailInfo |
res/layout/activity_detail.xml |
Added tv_prime_badge TextView |
ui/DetailActivity.kt |
Added tvPrimeBadge binding; bindDetail() uses info.isPrime |
After toggling a watchlist star on a season card in the All Seasons browse grid, the star
updated immediately (API call succeeded, adapter.submitList called). However, navigating away
(e.g., into a season detail page) and returning to the All Seasons grid caused all stars to
revert to their pre-toggle state.
BrowseActivity.onResume() rebuilt the adapter list with:
currentList.map { it.copy(watchProgressMs = resumeMap[it.asin] ?: it.watchProgressMs) }This .copy() call preserved isInWatchlist from the stale snapshot, effectively overwriting
the in-memory watchlistAsins set that toggleWatchlist() had already updated.
onResume() now also syncs isInWatchlist from watchlistAsins:
val updated = currentList.map { it.copy(
watchProgressMs = resumeMap[it.asin] ?: it.watchProgressMs,
isInWatchlist = watchlistAsins.contains(it.asin)
) }The watchlistAsins set is always kept current by toggleWatchlist(), so this one-liner is
sufficient — no extra API call needed.
| File | Change |
|---|---|
ui/BrowseActivity.kt |
onResume() refreshes isInWatchlist from watchlistAsins |
A user-facing Audio passthrough toggle added to the About / Settings screen (PLAYBACK panel,
below the existing video quality section). Persisted as "audio_passthrough" (Boolean) in
SharedPreferences("settings"). Default: false (PCM decode, unchanged behaviour).
DefaultRenderersFactory uses AudioCapabilities.DEFAULT_AUDIO_CAPABILITIES — a stub that
always reports no passthrough support. All AC3/EAC3 audio was decoded to PCM before the Android
audio mixer, so AV receivers never saw a Dolby bitstream even on capable hardware.
Enabling passthrough overrides buildAudioSink() to inject a DefaultAudioSink built with
AudioCapabilities.getCapabilities(context), which queries real HDMI output capabilities at
player-creation time.
| File | Change |
|---|---|
res/layout/activity_about.xml |
Added audio passthrough sub-section inside PLAYBACK panel: divider, title, tv_passthrough_badge, tv_passthrough_support, Off/On AppCompatButton row, tv_passthrough_note |
ui/AboutActivity.kt |
setupAudioPassthroughSection() — queries live HDMI caps (AudioCapabilities.getCapabilities), sets badge (AC3 + EAC3 capable / Passthrough unavailable), disables/dims On button when output has no support, reads/saves pref, Toast on change |
ui/PlayerActivity.kt |
Constants PREF_AUDIO_PASSTHROUGH, PREF_AUDIO_PASSTHROUGH_WARNED in companion object; setupPlayer() reads pref and builds passthrough DefaultRenderersFactory subclass overriding buildAudioSink(); one-time Toast warning gated by PREF_AUDIO_PASSTHROUGH_WARNED |
DefaultRenderersFactory subclass (anonymous object) overrides buildAudioSink():
object : DefaultRenderersFactory(this) {
init { setExtensionRendererMode(EXTENSION_RENDERER_MODE_OFF) }
override fun buildAudioSink(
context: Context,
enableFloatOutput: Boolean,
enableAudioTrackPlaybackParams: Boolean
): AudioSink = DefaultAudioSink.Builder(context)
.setAudioCapabilities(AudioCapabilities.getCapabilities(context))
.build()
}Fire TV has a system-level "Dolby Digital Plus" toggle (Settings → Display & Sounds → Audio).
If disabled there, AudioCapabilities.getCapabilities() reports no support and the On button
is greyed out — the app setting has no effect beyond what the OS permits.
DefaultRenderersFactory.buildAudioSink(Context, Boolean, Boolean)— protected, overriddenAudioCapabilities.getCapabilities(Context)— live HDMI queryAudioCapabilities.supportsEncoding(Int)— badge check (C.ENCODING_AC3/C.ENCODING_E_AC3)DefaultAudioSink.Builder(Context).setAudioCapabilities(…).build()
Problem: The header search button in activity_main.xml used the literal emoji 🔍 as
button text. On Fire TV / Android TV launchers and TV fonts, emoji glyph metrics rendered
clipped or vertically off-center.
Fix:
- Replaced
<Button>with<ImageButton>usingsrc=@drawable/ic_search - Added
ic_search.xmlvector drawable (standard Material magnifier path) scaleType=centerInside,padding=10dp, sameheader_icon_button_backgroundand tintMainActivity.kt:btnSearchIconchanged fromButtontoImageButtonstrings.xml: addedsearch_button_icon = "Open search"for accessibility
Icon now renders via vector paths — no dependency on font glyph metrics or emoji rendering.
Problem: The About screen PLAYBACK and ACTIONS buttons (btn_quality_*, btn_passthrough_*,
btn_sign_out) showed identical appearance for focused and selected states despite different
drawables being defined.
Root cause: The app theme (Theme.MaterialComponents.NoActionBar) maps <Button> to
MaterialButton, which applies its own colorPrimary (#00A8E0) tint via the Material tint
system on top of the background drawable. Setting android:backgroundTint="@null" alone does
not disable the Material-namespace app:backgroundTint, so the drawable state-list was being
overridden for all interactive states.
Fix: Changed all six option buttons in activity_about.xml to
<androidx.appcompat.widget.AppCompatButton>. AppCompatButton does not participate in the
Material tint system and uses android:background as the sole styling authority, allowing the
state-list drawable (settings_quality_option_background.xml) to fully control appearance.
State-list redesign (same file):
| State | Fill | Border | Text | Meaning |
|---|---|---|---|---|
| Rest | #0E1820 near-black |
1dp #1D2D36 |
#6E8590 muted |
inactive |
| Selected | #1B7A9E vivid teal |
2dp #2DC8EC |
#FFFFFF |
active setting |
| Focused | #131F28 dark |
3dp #5BCCE6 |
#FFFFFF |
cursor ring |
| Focused + Selected | #4FC0DF bright teal |
3dp #FFFFFF |
#061117 |
active + cursor |
| Disabled | #0C1318 |
1dp #172028 |
#3D5260 |
unavailable |
btn_passthrough_off and btn_passthrough_on had nextFocusUp="@id/btn_about_back" (copied
from the quality buttons), causing Up from the passthrough row to jump to the page Back button
instead of the quality row above it.
Fix: btn_passthrough_off → nextFocusUp="@id/btn_quality_hd" (left-aligned),
btn_passthrough_on → nextFocusUp="@id/btn_quality_uhd" (right-aligned).
Primary: Verify correctness, safety, and stability of everything added since Phase 21. Secondary: Assess overall maintainability — flag anything that would be hard to understand, extend, or debug by a developer unfamiliar with the codebase. This is not just a bug hunt; the reviewer should flag unclear abstractions, dead code, missing error surfaces, inconsistent patterns, and anything that would slow down future development.
| File | Changed in phase | Key new surface |
|---|---|---|
BrowseActivity.kt |
19 | Series → season → episode drill-down |
RailsAdapter.kt |
19 | Outer vertical ListAdapter for home carousels |
CardPresentation.kt |
19 | Focus scale animation, card variants |
ShimmerAdapter.kt |
22 | Skeleton loading animation |
UiMotion.kt |
22 | ValueAnimator / ObjectAnimator helpers |
UiTransitions.kt |
22 | Activity-level fade/slide transitions |
DpadEditText.kt |
22 | Fire TV remote keyboard interception |
DetailActivity.kt |
23 | Hero image, metadata, watchlist toggle, trailer |
AboutActivity.kt |
20, 26 | Quality section, audio passthrough section |
MainActivity.kt |
19, 24 | Rail filter, tab nav, shimmer integration |
PlayerActivity.kt |
21–26 | Overlay sync, H265 fallback, format label, passthrough renderer |
activity_about.xml |
26 | AppCompatButton state-list buttons, passthrough section |
settings_quality_option_background.xml |
26 | State-list drawable (rest/selected/focused/focused+selected) |
This is a full pass, not incremental. The reviewer must read the actual source, not rely on summaries.
- No token or credential values logged at any level in new activities or adapters
-
SharedPreferences("auth")not accessed outsideLoginActivity/AboutActivity -
DpadEditText— typed content not logged (search queries, login fields) -
DetailActivityintent extras validated before use (ASIN, title not trusted as safe) -
AboutActivity— Sign Out cannot be triggered accidentally (confirmation dialog present)
-
RailsAdapter/ContentAdapter— no anonymousHandlerorRunnableretainingActivitycontext after detach -
ShimmerAdapter— animation drawables / animators released inonViewRecycledoronDetachedFromRecyclerView -
UiMotion/UiTransitions—ValueAnimator/ObjectAnimatorcancelled when target view is detached -
PlayerActivity— coroutinescopeJobcancelled before re-creating player on H265 fallback path -
DetailActivity— coroutine scope cancelled inonDestroy; image loading cancelled on destroy -
PlayerActivity— no stale reference to releasedExoPlayerinstance afterreleasePlayer()
- No network calls dispatched on the main thread in any new code path
- Pagination / infinite scroll guards against duplicate in-flight requests
-
DetailActivity— all API errors surfaced to the user; no silent swallowing -
ShimmerAdaptershimmer hidden on both success and error (no infinite skeleton) -
RailsAdapter— pagination token not reused after end-of-feed
- All new interactive views declare
android:focusable="true"andandroid:focusableInTouchMode="false" -
DpadEditText— correctimeOptionsandinputTypefor TV on-screen keyboard -
RailsAdapterrail items —nextFocusDownfrom last rail row leads somewhere sensible (not into void) -
DetailActivity— every interactive element reachable by D-pad (metadata scroll, play, trailer, watchlist, seasons) -
CardPresentation— focus scale animation leaves no views in a permanently scaled-up state after fast scrolling or rapid focus changes -
AboutActivity— full D-pad traversal: Back → quality buttons → passthrough buttons → Sign Out, all reachable; Up from quality row goes to Back; Up from passthrough row goes to quality row
-
PlaybackQuality.fromPrefValue— safe fallback for unknown or null pref values -
PlayerActivity—h265FallbackAttemptedflag reset on eachsetupPlayer()call so it does not persist across content items in the same session -
MainActivity—activeSourceFilter/activeTypeFilterpreserved or reset correctly on back-stack pop -
UiMotion.revealFresh— handles views alreadyVISIBLEwithout flicker -
PREF_AUDIO_PASSTHROUGHread insidesetupPlayer()at player-creation time, not cached at activity start - Volume warning Toast fires at most once across all sessions (gated by
PREF_AUDIO_PASSTHROUGH_WARNED) -
AboutActivity— passthrough On button correctly disabled and dimmed whensupportsAny == false; pref not saved astruewhen capability is absent -
settings_quality_option_background.xml— state order correct:focused+selectedbeforeselectedbeforefocusedbefore default; disabled before all
This section assesses whether the codebase is easy to understand, extend, and debug — not just whether it works today.
- Naming: classes, functions, and variables named to convey intent without requiring inline comments
- Separation of concerns: UI logic not mixed into adapters; API parsing not mixed into Activities
- Dead code: no commented-out blocks, unused functions, unreachable branches, or stale TODOs left in production files
- Consistency: similar problems solved the same way throughout (e.g. coroutine scope management, error display, pref keys)
- Magic values: no unexplained hardcoded strings, numbers, or colours inline in Kotlin — constants or resources used throughout
- Error messages: error Toasts / dialogs give the user (and developer reading logcat) enough context to understand what went wrong
- Complexity: no function longer than ~60 lines or with cyclomatic complexity that would make it hard to test mentally; flag candidates for extraction
- Logging: sufficient
Log.w/Log.eat key decision points (player state changes, fallback triggers, auth events) that on-device debugging viaadb logcatis practical; no noisy or redundant log lines - SharedPreferences key hygiene: all pref keys defined as constants (not inline string literals scattered across files); keys in
PlayerActivity,AboutActivity,MainActivityconsistent and documented - Drawable / resource consistency:
AppCompatButtonused consistently for all state-list-driven buttons; no remaining<Button>elements that rely on drawable state-lists inTheme.MaterialComponentsactivities
Write findings to dev/review/review-findings-p27.md using the format:
## [Severity] Short title
File: path/to/File.kt:line
Issue: …
Suggestion: …
Severity levels: Critical (correctness / security break), Warning (likely bug or maintainability blocker), Info (minor improvement), OK (verified clean).
All Critical and Warning findings must be fixed before Phase 27 is marked COMPLETE.
dev/review/review-findings-p27.mdexists with every checklist item assessed- 0 Critical, 0 unresolved Warning findings
- Fix commit(s) SHA referenced in this section
Playback on Android emulators (and any un-provisioned hardware) failed with a DRM license
error. The root cause was confirmed by analysing the decompiled Amazon Prime APK
(prime-3.0.412.2947-smali/):
- Amazon's license server enforces: HD + Widevine L3 + no HDCP → license DENIED
- SD quality bypasses the restriction: SD + L3 + no HDCP → license GRANTED
- The official APK's
ConfigurablePlaybackSupportEvaluatorqueriesHdcpLevelProviderand automatically falls back to SD whenHDCP = NO_HDCP_SUPPORTis detected. - Our code hardcoded HD quality regardless of device capability, so every emulator playback attempt returned a license denial.
Query MediaDrm.getPropertyString("securityLevel") before player creation.
If the result is not "L1", force PlaybackQuality.SD for the session regardless of the
user's quality preference — the license server will not grant HD to an L3 device.
A one-time Toast informs the user on first L3 detection; subsequent plays are silent.
model/PlaybackQuality.kt— addedSDpreset ("SD","H264","None") with doc comment explaining the license server enforcement rule.videoQualitycomment updated to list"SD"as a valid value.ui/PlayerActivity.kt:import android.media.MediaDrmaddedPREF_WIDEVINE_L3_WARNEDconstant added to companion objectwidevineSecurityLevel()helper: opensMediaDrm(WIDEVINE_UUID), reads"securityLevel"; falls back to"L3"on any exception (safe-fail)resolveQuality(): L3 gate inserted before the user-preference check; returnsSDwhen security level is not"L1"; fires one-time Toast gated byPREF_WIDEVINE_L3_WARNEDupdatePlaybackStatus():PlaybackQuality.SDcase maps to label"SD (Widevine L3)"- Cleaned up redundant
android.widget.Toastfully-qualified references inresolveQuality()(now uses the already-importedToast)
- Emulator: playback now succeeds (SD quality, H264 manifest)
- Fire TV (L1): unaffected — L3 gate passes immediately, existing quality logic runs
- Decision 22 added to
decisions.md
A Continue Watching rail prepended to the home screen rails list, built entirely from server-side watchlist progress data (no local tracking).
Smali analysis of the decompiled Prime Video APK (ContinueWatchingCarouselProvider) confirmed:
- Amazon's own CW row reads from a local SQLite
UserActivityHistorydatabase populated during playback — no server read endpoint exists for in-progress item data. - The watchlist API never returns episode-level items.
- The v1/v2 home page APIs return editorial content, not personalised in-progress data.
Decision: Use watchlist API progress only (watchProgressMs > 0 && runtimeMs > 0).
AmazonApiService.getWatchlistData(): returns aTriple<Set<String>, Map<String, Pair<Long,Long>>, List<ContentItem>>— the third element iswatchlistInProgressItems(movies/series with server-confirmed watch progress). Also callsgetHomePage()to supplementprogressMapfor non-watchlist items (including in-progress episodes from the v1 home page).ContentItemParser.kt: fixedClassCastException—getAsJsonArray("entitlementMessageSlotCompact")crashed onJsonNullfields; replaced withsafeArray().MainActivity.kt:- Added
watchlistInProgressItems: List<ContentItem>field - Added
buildContinueWatchingRail()— combineswatchlistInProgressItems+ in-progress items fromunfilteredRails - CW rail prepended to
displayListinshowRails(),loadHomeRailsNextPage(),applyRailsFilters(), andonResume()— afterapplyAllFiltersToRails()so the row bypasses filters updateHomeFeaturedStrip()overrides meta line toprogressSubtitle()when CW is the first rail- Removed all
SharedPreferences("resume_positions")reads from MainActivity (5 locations) — progress is now server-sourced only
- Added
RailsAdapter.kt: storecontentAdapter+boundPresentationinRailViewHolder; reuse the adapter on rebind if presentation matches (eliminates async-diff empty-frame flicker); addedonViewRecycled()to clear the inner adapter list; pool contamination fix applied inContentAdapter.ContentAdapter.kt: addedgetItemViewType()returningpresentation.ordinal— prevents cross-presentation holder reuse via the sharedRecycledViewPool.
- Home screen shows "Continue Watching" as the first rail with amber progress bars on all cards
- Hero strip shows "CONTINUE WATCHING" eyebrow + "X% watched · Y min left" meta line
- Source/type filters do not hide the CW row
- Progress bars render correctly on all cards including position 0 (first item)
- Decision 23 added to
decisions.md
The player stability pass (pre-Phase 29) had added local SharedPreferences("resume_positions")
writes to PlayerActivity — periodic saves every 30 s via resumeProgressRunnable, forced saves
on pause/stop/seek/error — so that resume position persisted across cold starts. This duplicated
the server's own position tracking (UpdateStream / PES V2 heartbeats already report position
continuously) and used a different storage path from the watchlistProgress map that drives
progress bars and the Continue Watching row: a title watched partially via local storage would
show no progress bar on the home screen until the watchlist API was re-queried.
BrowseActivity also had two local reads that were missed in the initial removal.
Remove all local resume storage from PlayerActivity and BrowseActivity. Resume position is
now passed by callers via intent extras sourced from the server watchlistProgress map.
The existing UpdateStream + PES V2 heartbeat logic (START/PLAY/PAUSE/STOP) that reports
position to the server is fully intact; remainingTimeInSeconds in the watchlist API reflects
the last reported position after a session.
The v1 home page supplement in getWatchlistData() already covers episodes: Amazon's watchlist
API skips episode-level items, but in-progress episodes appear in the v1 landing page with
remainingTimeInSeconds > 0 and are merged into watchlistProgress at startup.
PlayerActivity.kt:
- Removed fields:
resumePrefs,lastResumeSaveElapsedMs,resumeProgressRunnable - Removed methods:
persistPlaybackProgress(),saveResumePosition(),startResumeProgressUpdates(),stopResumeProgressUpdates() - Removed ~12 call sites across
playerListener,startStreamReporting,stopStreamReporting,onPause,onStop,onDestroy - Added
h265FallbackPositionMs: Long— holds live position when H265 CDN returns 400; player restart uses this over the intent extra and resets it to 0 after use setupPlayer()reads resume fromintent.getLongExtra(EXTRA_RESUME_MS, 0L)- Added
EXTRA_RESUME_MS = "extra_resume_ms"constant - Server-side UpdateStream / PES V2 reporting unchanged
Intent chain for resume position (EXTRA_RESUME_MS):
| Caller | Value | Destination |
|---|---|---|
MainActivity.onItemSelected() |
watchlistProgress[asin]?.first ?: item.watchProgressMs |
DetailActivity |
DetailActivity.onPlayClicked() |
serverResumeMs (from own intent) |
PlayerActivity |
BrowseActivity episode play |
item.watchProgressMs |
PlayerActivity |
BrowseActivity.kt:
- Removed both local
SharedPreferences("resume_positions")reads (initial load +onResume) - Added
EXTRA_PROGRESS_MAP— receivesHashMap<String, Long>(ASIN → progressMs) from callers - Initial load merges
serverProgressMapinto episode items onResumeafter player return: refreshes watchlist star state only (no local progress update)
DetailActivity.kt:
- Added
EXTRA_RESUME_MSandEXTRA_PROGRESS_MAPconstants - Reads both from intent; forwards
EXTRA_PROGRESS_MAPto both BrowseActivity launches
MainActivity.kt:
- Passes
HashMap(watchlistProgress.mapValues { it.value.first })asDetailActivity.EXTRA_PROGRESS_MAP
| Scenario | Resume works? |
|---|---|
| Movie or series in watchlist | ✓ server remainingTimeInSeconds |
| Episode (series in watchlist) | ✓ v1 home page supplement |
| Title not in watchlist | ✗ no server data, no local fallback (→ Phase 30) |
| H265 CDN fallback (H264 restart) | ✓ h265FallbackPositionMs instance variable |
| Trailer | ✗ always starts from 0 |
2aa4638—refactor: replace local resume-position SharedPreferences with server-sourced intent extraad55485—fix: remove local SharedPreferences resume reads from BrowseActivity, use server progress
Phase 30 replaces the old progress intent chain with a single ProgressRepository used by
Home, Browse, Detail, Player, and logout handling.
New singleton:
data/ProgressRepository.kt- in-memory ASIN →
(positionMs, runtimeMs)map SharedPreferences("progress_cache")persistencerefresh(),update(),get(),getInProgressItems(),getInProgressEntries(),clear()
- in-memory ASIN →
Player integration:
PlayerActivitynow:- reads initial resume from
ProgressRepository.get(asin) - writes progress back via
ProgressRepository.update(...) - persists progress every 30 seconds during playback and on pause/stop/seek/error
- reads initial resume from
Screen integration:
MainActivity,BrowseActivity, andDetailActivityno longer pass progress throughEXTRA_RESUME_MS/EXTRA_PROGRESS_MAP- Home and Browse re-read progress directly from the repository
- About/logout clears the repository cache
Continue Watching expansion:
- CW still prefers server-backed in-progress items
- Home now also resolves a small capped set of local-only in-progress ASINs by detail lookup and can surface them in Continue Watching without introducing a full metadata repository
Refresh policy:
- local cached entries are loaded from
SharedPreferences("progress_cache") - server progress from
getWatchlistData()is applied over that map - during active playback after refresh, local writes can become newer again
Effective rule:
- startup / explicit refresh: server wins
- active playback after refresh: local writes win until next refresh
ProgressRepositoryis now the single progress source used by all main screens- resume no longer depends on intent pass-through
- Browse/Home can pick up updated progress after returning from playback
- non-watchlist titles with local progress can now appear in Continue Watching if their ASIN can be resolved through the detail API
./gradlew assembleReleasepassed- emulator smoke:
MainActivityresumed cleanly after cold launchProgressRepositoryrefresh succeeded (entries=6, inProgressItems=5)Continue Watchingrail rendered on Home- repository contained at least one extra local-only entry beyond the server-backed in-progress set, confirming the fallback path is meaningful for this account
- there is still no trustworthy backend
lastUpdatedAt, so cross-device conflict resolution is still server-first on refresh rather than true newest-wins
After Phase 30 landed, one regression remained:
- Continue Watching clicks always went through
DetailActivity - server-backed episode progress could be visible on the CW card but lost at playback start if the local cache had been cleared
Fix:
MainActivity/BrowseActivity/DetailActivitynow passPlayerActivity.EXTRA_RESUME_MSwhen they already have a visible resume position for the selected itemPlayerActivitynow prefers:- H265 fallback resume
- explicit intent resume
- repository resume
- Home Continue Watching is now rail-aware:
- movies / episodes direct-play
- series / seasons still open overview flows
- local-only CW metadata resolution now prefers
getDetailPage(asin)exact-item matches before falling back togetDetailInfo(asin), which helps preserve episode identity
Validation:
- emulator: Continue Watching movie direct-play opened
PlayerActivitydirectly - Fire TV:
Falloutepisode resume worked again from server-backed progress even after local cache was cleared
Four focused polish items spanning the detail page, home hero strip, content card labels, and the player seekbar.
Goal: When the user opens a detail page for a title they have partially watched, show a progress indicator so they can see where they left off before tapping Play.
What to show:
- An amber horizontal
ProgressBardirectly below the hero image or above the Play button area - A text label using the same format as card subtitles:
"X% watched · Y min left"or"Resume from Xh Ym" - When
watchProgressMs == -1Lor fully watched (≥ 90%): show"Finished recently"and no bar
Data source: ProgressRepository.get(asin) is already called in onCreate() (the
serverResumeMs field stores the intent-passed resume ms; fall back to repository if not set).
Runtime is available from DetailInfo.runtimeSeconds * 1000.
Files to change:
| File | Change |
|---|---|
res/layout/activity_detail.xml |
Add ProgressBar (horizontal, amber tint, 0dp height=4dp) + TextView above the button row |
ui/DetailActivity.kt |
Bind both views; after loadDetail() resolves DetailInfo, call bindProgress() which reads ProgressRepository.get(asin) and either shows or hides the bar |
ui/UiMetadataFormatter.kt |
Add detailProgressLine(posMs, runtimeMs): String? — same logic as progressSubtitle() but returns null when no progress |
Edge cases:
runtimeMs == 0L: hide progress bar (can't draw a meaningful fraction)watchProgressMs > 0L && runtimeMs > 0L && fraction >= 0.9: show "Finished recently", no barmaterialType == "Trailer": never show resume state
Goal: The two buttons currently differ only in text. Make them visually distinct at a glance.
| Button | Current | Target |
|---|---|---|
| Play / Resume | Text only | Leading ▶ vector icon (filled play triangle) |
| Trailer | Text only | Leading ◻ / filmstrip-style icon (hollow play or clapper icon) |
Implementation:
- Add
ic_play_filled.xmlvector drawable (standard Materialplay_arrowpath, 20dp) - Add
ic_trailer.xmlvector drawable (outline play circle ormovieicon, 20dp) - Set
android:drawableStarton eachAppCompatButtoninactivity_detail.xml - In
DetailActivity.bindDetail(), swapbtnPlaytext to"▶ Resume"whenposMs > 10_000,"▶ Play"otherwise (icon stays the same; text signals resume vs fresh start) btnTraileralways shows its film icon regardless of progress
Goal: The featured hero image at the top of the home screen should show an amber progress bar at the bottom of the image when the featured item has watch progress, matching the progress bar on the Continue Watching rail cards.
Current state: The hero FrameLayout (home_featured_strip) has a hero image, gradient
overlay, eyebrow/title/meta TextViews, and a click handler. No progress indication.
Implementation:
| File | Change |
|---|---|
res/layout/activity_main.xml |
Add ProgressBar (horizontal, layout_gravity="bottom", full width, 4dp height, amber tint, visibility="gone") inside home_featured_strip FrameLayout, z-ordered above the gradient overlay |
ui/MainActivity.kt |
In updateHomeFeaturedStrip(): after resolving nonNullFeaturedItem, call ProgressRepository.get(item.asin) and set bar progress / visibility. Use max=1000, progress=(posMs * 1000 / runtimeMs).toInt(). Hide when watchProgressMs == 0L or runtimeMs == 0L |
Note: The bar should be shown for the CW hero (which always has progress) AND for any other featured item that happens to have partial progress.
Current state: UiMetadataFormatter.secondaryLine() (line 127) falls back to
"Feature film" for movies when the API-sourced subtitle field is empty. This text is
redundant — the card overline already says "Movie" — and slightly formal/awkward.
Source: UiMetadataFormatter.kt, secondaryLine():
item.isMovie() -> parts += "Feature film"Fix: Remove this fallback line. When a movie has no API subtitle and no watch progress,
the subtitle area on the card will be blank rather than showing a redundant label. The overline
"Movie" (or "Continue Watching" if in progress) provides sufficient context.
Also check landscapeSubtitle() — it delegates to secondaryLine() for movies so the same
fix covers landscape cards (e.g., Continue Watching cards with no progress).
| File | Change |
|---|---|
ui/UiMetadataFormatter.kt |
Remove item.isMovie() -> parts += "Feature film" line from secondaryLine() |
Unit test UiMetadataFormatterTest.kt should be updated to verify blank subtitle for movies
with no subtitle and no progress.
Goal: Show a small image preview above the seekbar while the user scrubs (as in the official Prime Video app), so they can see what scene they are seeking to before releasing.
Amazon DASH manifests typically include an image adaptation set — a "trick play" track — that provides thumbnail sprite sheets at regular intervals:
<AdaptationSet mimeType="image/jpeg" contentType="image">
<SegmentTemplate media="$Number$" timescale="1" duration="10" startNumber="1">
...
</SegmentTemplate>
<Representation id="thumbnail" width="1920" height="90" bandwidth="512">
...
</Representation>
</AdaptationSet>Each sprite sheet image contains multiple frames arranged in a grid. The number of columns and
rows per sprite and the per-frame width/height are encoded in the adaptation set attributes or
in a <EssentialProperty> with scheme urn:mpeg:dash:thumbnail:2013.
Amazon also sometimes includes a dedicated thumbnails URL in the GetPlaybackResources response
body as a separate property (not always present; varies by title and region).
- Capture a live
GetPlaybackResourcesresponse for a HD title and a 4K title — check if there is a thumbnail/trickplay track in the MPD XML. - If present in the MPD, parse:
SegmentTemplate duration→ interval between frames (seconds)Representation width/height→ sprite sheet total dimensions- Number of columns and rows (often found in
@saror essential property) - Template URL pattern
- If not in the MPD, check the
GetPlaybackResourcesJSON response top level for athumbnailTrack/trickPlayTrackproperty.
-
PlayerActivity.kt:- After manifest is loaded, extract
thumbnailTrackUrl,frameIntervalSec,frameWidth,frameHeight,framesPerRowfrom thePlaybackInfomodel (add new fields) - Attach
DefaultTimeBar.OnScrubListenerto the seekbar:onScrubStart/onScrubMove(position: Long)→showThumbnailAt(position)onScrubStop→hideThumbnail()
- After manifest is loaded, extract
-
showThumbnailAt(positionMs):frameIndex = (positionMs / 1000 / frameIntervalSec).toInt()sheetIndex = frameIndex / (framesPerRow * framesPerRow)(frames per sheet)col = frameIndex % framesPerRowrow = (frameIndex / framesPerRow) % framesPerRow- Request the sprite sheet image URL via
Coil(cacheable), crop the frame as aBitmapusingBitmapRegionDecoder - Display in a floating
ImageView/CardViewabove the seekbar at the x-position corresponding to the seek position
-
Layout (
activity_player.xml):- Add a
CardView+ImageView(id=iv_seek_thumbnail) withvisibility="gone", fixed size (e.g., 160×90dp), positioned above theexo_progressbar alpha=0while hidden; fade in on scrub start
- Add a
-
AmazonApiService.kt/PlaybackInfo.kt:- Add
thumbnailTrackUrl: String,frameIntervalSec: Int,spriteColumns: Int,spriteRows: Int,frameWidthPx: Int,frameHeightPx: InttoPlaybackInfo - Parse from DASH manifest (or
GetPlaybackResourcesJSON) ingetPlaybackInfo()
- Add
BitmapRegionDecoderdecodes only JPEG and PNG — matches Amazon's sprite format- Coil image loading must be done off the main thread;
BitmapRegionDecodermust not be called on UI thread - The
DefaultTimeBar.OnScrubListenerinterface must be set via(playerView.findViewById<DefaultTimeBar>(R.id.exo_progress))?.addListener(scrubListener) - If no thumbnail track is found for a title, the feature degrades gracefully (no preview shown)
- Trailer playback: thumbnails should be disabled (thumbnail track likely absent for trailers)
- Detail page shows progress bar + "X% watched · Y min left" for partially watched titles
- Play button shows
▶ Resumewhen progress > 10 s;▶ Playotherwise - Trailer button has a distinct icon (
▷outline triangle vs▶filled) - Home hero strip shows amber progress bar when featured item has watch progress
-
"Feature film"fallback text removed from movie cards - Thumbnail preview appears above seekbar on scrub for titles that have a trick play track (deferred — requires DASH manifest investigation)
- Thumbnail degrades gracefully (no crash, no UI artifact) when track is absent (deferred)
-
./gradlew assembleReleasepasses; no new warnings
| Item | File(s) changed | Notes |
|---|---|---|
| Remove "Feature film" | UiMetadataFormatter.kt |
Removed fallback from secondaryLine(); overline already says "Movie" |
| Detail page progress | activity_detail.xml, DetailActivity.kt |
Amber ProgressBar + "X% watched · Y min left" TextView below metadata; reads ProgressRepository.get(asin) |
| Play/Resume button | DetailActivity.kt |
bindDetail() sets text to "▶ Resume" when posMs > 10 s, "▶ Play" otherwise |
| Trailer icon | activity_detail.xml |
Changed ▶ Trailer → ▷ Trailer (outline triangle = preview/trailer semantic) |
| Hero strip progress bar | activity_main.xml, MainActivity.kt |
iv_home_featured wrapped in FrameLayout; amber ProgressBar at bottom; wired via ProgressRepository.get() in updateHomeFeaturedStrip() |
| Seekbar thumbnail preview | — | Deferred to a later phase — requires DASH trick-play track investigation per title |
A full audit of Phase 31's implementation was conducted after shipping.
See dev/phase31-analysis.md for the detailed findings.
Summary of issues found:
| ID | Priority | Description |
|---|---|---|
| P0-A | P0 | -1L sentinel (fully-watched) produces blank detail page — "Finished recently" never shown |
| P0-B | P0 | No onResume() in DetailActivity — button text and bar stale after returning from player |
| P1-A | P1 | Progress % formula diverges: detail page double-truncates vs card subtitle single-truncation |
| P1-B | P1 | OTHER type → "MOVIE" on detail page eyebrow, "Featured" on card overline |
| P1-C | P1 | Season detail suppresses Trailer button even when isTrailerAvailable = true |
| P2-A | P2 | Redundant pbWatchProgress.max = 1000 in code couples to XML value silently |
| P2-B | P2 | Watchlist toggle has no in-flight guard — double-tap sends duplicate API calls |
| P2-C | P2 | getInProgressEntries() has redundant != -1L (-1L > 0L already excludes it) |
| P2-D | P2 | refresh() can overwrite newer local progress with older server value |
| P3-A | P3 | Series detail page has no last-episode resume shortcut |
| P3-B | P3 | No TTL or background refresh for ProgressRepository |
| P3-C | P3 | seriesAsin not set in detailInfoToContentItem() — blocks future series resume |
| P3-D | P3 | contentLabel() catch-all "Featured" is misleading for movie-like unknown types |
P0 and P1 fixes are planned for the next phase alongside the seekbar thumbnail feature.
- Applied the high-value P0/P1 fixes and selected low-risk P2 fixes identified in the Phase 31 post-ship analysis
- See
dev/phase31-analysis.mdfor issue details and fix rationale
| Issue | Fix |
|---|---|
P0-A -1L sentinel invisible on detail page |
bindProgress() guard changed posMs <= 0L → posMs == 0L; new explicit -1L branch shows "Finished recently" (no bar) |
| P0-B Detail page stale after returning from player | Added onResume() in DetailActivity that re-reads ProgressRepository and updates button text + progress bar |
| P1-A Percent formula diverges between detail page and card | Extracted progressText(posMs, runtimeMs) in UiMetadataFormatter; both surfaces delegate to it |
P1-B OTHER type label inconsistency ("MOVIE" vs "Featured") |
Unified contentLabel() and defaultOverline() catch-all to "Movie" |
P1-C Season trailer button suppressed by !isSeries |
Fixed to `(!isSeries |
P2-A Redundant pbWatchProgress.max = 1000 |
Removed from bindProgress(); XML declaration is the single source of truth |
| P2-B Watchlist double-tap sends duplicate API call | Added watchlistUpdateInFlight boolean with try/finally in onWatchlistClicked() |
P2-C getInProgressEntries() redundant != -1L |
Simplified to positionMs > 0L (the extra condition was already implied) |
| Test | Result |
|---|---|
| P0-B: The Wrecking Crew — Play → watch 20 s → Back → detail page | ✅ Button changed to "▶ Resume", "1% watched · 123 min left" visible |
| P1-A: Borderlands detail page vs CW card subtitle | ✅ Both show "11% watched · 86 min left" — canonical formatter confirmed |
| P1-B: Unknown-type content detail page eyebrow | ✅ Shows "MOVIE" (was "Featured") |
P1-C: The Wrecking Crew — movie with isTrailerAvailable = true |
✅ "▷ Trailer" button visible |
P0-A: -1L sentinel → "Finished recently" |
Logic-verified via code inspection (no fully-watched title available in test data) |
- P3-A: Series detail last-episode resume shortcut
- P3-B: Background progress refresh / TTL
- P3-C:
seriesAsinnot set indetailInfoToContentItem() - P2-D alternative merge policy — server-first refresh was kept to preserve Decision 25 and README behavior
- Seekbar thumbnail preview (deferred from Phase 31)
Deliver the two highest-value remaining playback UX improvements:
- Player scrub preview overlay — the small preview window shown above the seekbar while the user scrubs left/right in the player controls, matching the behavior of the official app.
- Series detail resume shortcut — a direct
Resume Episodeaction on series detail pages when the app already knows the user has in-progress episode data for that show.
This phase should stay focused on playback/navigation quality, not broader metadata caching or cross-device sync redesign.
When the user focuses the player seekbar and presses left/right:
- a small preview card appears above the seekbar
- it updates as the target position changes
- it hides when scrubbing stops or when the player controls auto-hide
This preview is an image thumbnail, not a second video player.
When the user opens a series detail page and the app can identify an in-progress episode:
- the primary action becomes
▶ Resume Sx Ex - selecting it starts playback for that episode directly
Browse Seasonsremains available as the fallback navigation path
Amazon likely exposes seek previews through a DASH image adaptation set ("trick-play" track). Implementation should prefer manifest- or playback-resource-driven metadata, not runtime video frame extraction.
Model changes:
Extend PlaybackInfo with optional thumbnail metadata:
val thumbnailTrackUrl: String = ""
val frameIntervalSec: Int = 0
val spriteColumns: Int = 0
val spriteRows: Int = 0
val frameWidthPx: Int = 0
val frameHeightPx: Int = 0API layer:
AmazonApiService.getPlaybackInfo()- inspect
GetPlaybackResources - if no direct thumbnail metadata is present, fetch/parse the MPD
- detect image/trick-play adaptation sets
- inspect
- expected inputs:
- sprite sheet URL template
- frame interval
- sprite grid dimensions
- frame dimensions
Important rule:
- if no trick-play metadata is found, leave all fields empty/zero and degrade gracefully
Files:
app/src/main/res/layout/activity_player.xmlapp/src/main/java/com/scriptgod/fireos/avod/ui/PlayerActivity.kt
Layout:
- add a small floating thumbnail card above
exo_progress - suggested size: about
160dp x 90dp - hidden by default
Player logic:
- attach
DefaultTimeBar.OnScrubListener - on scrub start / move:
- compute frame index from target position
- compute sprite sheet index + crop rect
- load sprite sheet off the main thread
- crop the correct frame with
BitmapRegionDecoder - show/update the preview card
- on scrub stop:
- hide preview card
Visibility behavior:
- preview must disappear with the player controls
- no independent linger/flicker behavior
Files:
app/src/main/java/com/scriptgod/fireos/avod/ui/DetailActivity.ktapp/src/main/java/com/scriptgod/fireos/avod/data/ProgressRepository.ktapp/src/main/java/com/scriptgod/fireos/avod/ui/MainActivity.kt
Needed data work:
- preserve enough parent-series identity on episode items
- specifically ensure locally resolved progress-backed items keep:
seriesAsinshowIdseasonIdwhen available
Series detail behavior:
- if current page is a series and an in-progress episode for that series is known:
- render
▶ Resume Sx Ex - wire it directly to
PlayerActivity - pass
EXTRA_ASIN,EXTRA_TITLE,EXTRA_CONTENT_TYPE, andEXTRA_RESUME_MS
- render
Selection rule:
- prefer the most recently progressed episode if that can be inferred
- otherwise prefer the furthest-progress episode for the series
Keep the current server-first merge policy, but allow lightweight refresh on long-lived sessions:
- if Home resumes after a small TTL (for example 5-10 minutes), refresh progress
- otherwise reuse in-memory repository state
This is intentionally not a background sync feature.
- full metadata repository / offline content cache
- true timestamp-based conflict resolution
- background worker for progress sync
- broader player redesign beyond scrub preview
- Investigate trick-play metadata in
GetPlaybackResources/ MPD - Add thumbnail fields to
PlaybackInfo - Implement player scrub preview overlay
- Fix parent-series identity on progress-backed items
- Implement series detail resume CTA
- Add simple repository TTL
- thumbnail preview appears above the seekbar for titles with trick-play metadata
- no preview is shown, and no errors occur, for titles without trick-play metadata
- preview hides in sync with player controls
- series detail pages can show
Resume Episodewhen in-progress episode data exists Resume Episodestarts the correct episode with the correct resume position./gradlew assembleReleasepasses
Root cause discovery: parseThumbnailTrack() looked for DASH image adaptation sets,
but Amazon does not embed thumbnail tracks in the MPD. Decompiled APK smali revealed the
real mechanism: TrickplayPlugin$DownloadBifFileFromUrl + Resource enum value
"TrickplayUrls".
Solution:
- Added
TrickplayUrlstodesiredResourcesinGetPlaybackResources AmazonApiService.extractBifUrl()parsestrickplayUrls.trickplayUrlsCdnSets[].trickplayUrlInfoList[], prefers 480pPlaybackInfo.bifUrlreplaces the old sprite-sheet fieldsPlayerActivity.loadBifIndex()downloads BIF header+index (64 B header, then N+1 × 8 B index) via HTTP RangePlayerActivity.showThumbnailAt()binary-searches the index and range-fetches individual JPEG frames on demand; LRU-caches up to 10 frames- D-pad LEFT/RIGHT accumulates
seekPreviewPosand shows the thumbnail overlay;dpadSeekHandlerhides it 1.5 s after the last key press - Confirmed working on device (user: "works")
Commits: f8d43b5
Series resume CTA (DetailActivity):
updateSeriesResumeCta(info)helper checks server-backedgetInProgressItems()first, falls back togetLocalProgressForSeries()for episodes watched but not yet server-refreshed- Called from both
bindDetailInfo()andonResume()so the CTA appears immediately on return from playback - Button shows
▶ Resume SxExwhen season/episode numbers are available; generic▶ Resume Episodefor local-only entries where metadata is absent
ProgressRepository changes:
ProgressEntrygainsseriesAsin: String = ""(Gson-backward-compatible)update()accepts optionalseriesAsin; stored per entrygetLocalProgressForSeries(seriesAsin)returns the highest-positionMs local entry
PlayerActivity / BrowseActivity:
EXTRA_SERIES_ASINconstant; read on startup; threaded through to everyupdate()callBrowseActivitypassesitem.seriesAsinwhen launching episodes
TTL refresh flat-grid fix (MainActivity):
- TTL coroutine now has
else if (adapter.currentList.isNotEmpty())branch so Watchlist / Library / search results are also rebound after the async server refresh
Commits: 3ab9b56
- ✅ Thumbnail preview appears above seekbar for titles with trick-play metadata
- ✅ No preview shown (no errors) for titles without trick-play metadata
- ✅ Preview hides in sync with player controls (1.5 s dpad idle)
- ✅ Series detail pages show
Resume EpisodeCTA for in-progress episodes (server + local) - ✅
Resume Episodestarts correct episode with correct resume position - ✅
./gradlew assembleReleasepasses - ✅ TTL refresh updates flat-grid surfaces (Watchlist, Library, search)
PlayerActivity: newEXTRA_SEASON_ASINconstant andcurrentSeasonAsinfield; reads season ASIN from intent ononCreate;STATE_ENDEDnow callsonPlaybackCompleted()onPlaybackCompleted(): trailers and movies (no season context) callfinish(); episodes fetch the season's episode list viagetDetailPage, binary-search for the current episode, and auto-play the next one if it exists; last episode of season callsfinish()BrowseActivity: passesitem.seasonIdasEXTRA_SEASON_ASINwhen launching player for an episodeDetailActivity.onResumeEpisodeClicked(): passesepisode.seasonIdasEXTRA_SEASON_ASIN
| Scenario | Outcome |
|---|---|
| Trailer ends | finish() |
| Movie ends | finish() |
| Episode ends, next exists | auto-play next episode |
| Last episode of season | finish() → returns to season BrowseActivity |
| API lookup fails | finish() (safe fallback) |
| Next episode partially watched | resumes from ProgressRepository |
| User presses Back during API call | coroutine cancelled in onDestroy, no crash |
- ✅
./gradlew assembleReleasepasses, no new warnings - ✅ Movie ends →
finish()confirmed on device (logcat:onPlaybackCompleted → finish (no season/trailer)) - ✅ Episode ends → next episode auto-plays confirmed on device (logcat:
onPlaybackCompleted → auto-play next episode,loadAndPlaycalled with new ASIN, same season ASIN) - Note:
item.seasonIdis empty for episodes listed in BrowseActivity; fixed by storingbrowseAsinas class field and using it as fallback whencurrentFilter == "episodes" - ✅ CW rail removes finished items on next
onResume()—buildContinueWatchingRail()applieswithRepositoryProgress+filter { watchProgressMs > 0 } - ✅ Home CW direct-play passes
EXTRA_SEASON_ASIN—openPlayer()updated;withRepositoryProgress()propagatesProgressEntry.seasonAsin→ContentItem.seasonId - ✅
ProgressEntrystoresseasonAsin;persistPlaybackProgress()writescurrentSeasonAsin - ✅ Local-only series resume
ContentItemsynthesis setsseasonIdfromProgressEntry.seasonAsin - ✅ Old
ExoPlayerreleased insetupPlayer()before building a new instance (no renderer leak)
Root cause: Amazon's MPD uses <SegmentList duration="351"> (3.656 s/segment fixed), but actual
segment durations vary 2.625–5.0 s. Over 2087 segments the cumulative drift reaches ~41 s at the
56-minute mark. ExoPlayer calculated segment index 920 but that segment contained data for ~3322 s
(41 s behind). All frames were dropped as stale; the buffer filled with un-renderable data.
Validated: Original MPD → permanent stall at 3367850 ms. Corrected MPD → plays through cleanly. Same title, same device, same DRM. Only variable changed: segment timing mechanism.
MpdTimingCorrector.kt— convertsSegmentList(fixed inaccurate duration) toSegmentBase+indexRangeso ExoPlayer reads the sidx box for accurate per-segment timing; probes sidx size from one file (8-byte Range request), reuses it for all representations (same segment count = same sidx box size = 25076 bytes)- Custom
DataSourceinterceptor inPlayerActivityserves the corrected MPD in-memory; original CDN URI kept asMediaItemsoBaseURLresolution works; falls back to original MPD on any error - Stall watchdog bug fixes (3):
playWhenReadyused instead ofisPlaying(false during BUFFERING); watchdog no longer cancelled/restarted on everyBUFFERING↔READYoscillation (~340 ms cycle); position counter no longer reset byisPlayingChangedon buffering transitions StallRecoveryVideoRenderer(customMediaCodecVideoRenderersubclass) unwired — caused a pause regression (800 ms threshold fired while paused); MPD correction is the primary fix; position-level watchdog retained as safety net- DRM license protocol rewritten to match
playback.py—widevine2Challengeform-encoded POST,widevine2License.licenseresponse key;buildLicenseUrl()rebuilt as fullGetPlaybackResourcesendpoint with correct param set forceAVCflag andSD_AVC/HD_AVCquality presets removed — flag was never sent in the request- Network logging: MPD, video/audio
.mp4segment, and license requests logged with full URL;onMediaRequestObservedcallback added toAmazonAuthServicefor segment tracking - Root cause analysis:
dev/analysis/mpd-timing-stall-fix.md