The Flutter UI
Everything the Flutter app does, module by module, with the design decisions behind it.
The C++ server, the Python plugins, libmpv and the cloud workers are unchanged. Only the screen layer is new:
SplashActivity (launcher: starts Boot, plays the unskippable splash video full screen via SplashOverlay.kt (a TextureView, so a video that cannot play leaves the orange cover, never black), and when the video is over opens FlutterHostActivity on top, see-through until Flutter's first frame; SplashGate.kt closes the splash when Flutter has sent pek/host splashReady. Boot runs the server and plugins during the video, one window at a time, nothing hidden at alpha 0) ; FlutterHostActivity ; MainActivity (hand-over for widget/notification taps) ; Boot = assets, server on :8080, plugins
-> FlutterHostActivity android/app/src/flutter/java/.../FlutterHostActivity.kt
Flutter UI (flutter_ui/) --HTTP--> C++ server on 127.0.0.1:8080
pek/mpv channels -------> MpvController (libmpv, same engine) -- SurfaceView behind FlutterBuild #
cd flutter_ui && flutter pub get(generatesflutter_ui/.android/, which switches the Flutter build on)- Build the Android project as usual. Step 1 is required: the app has no other UI.
- CI:
.github/workflows/build_android.yml(the Android build) runs step 1 automatically whenflutter_ui/pubspec.yamlexists.
The Flutter UI is the only UI.
One branch, one folder per platform #
Everything lives on main. flutter_ui/ (screens) and backend/ (C++ server) are shared; each platform has its own host folder next to them. Today that is android/ with its workflow .github/workflows/build_android.yml. A new platform adds its own folder (for example linux/ or windows/) and its own workflow file; it does not need a branch. A host has to provide what Android's host does: start the backend on 127.0.0.1:8080, and answer the pek/* platform channels (pek/mpv, pek/music, pek/host, pek/plugins, pek/output) that flutter_ui/lib/src/ calls. Some pieces here are Android-only today (the Kotlin side of those channels, webview_flutter_android for plugin trailers).
What the Flutter UI has now #
Swipe navigation on phones and tablets: Home, Movies, Shows, Music and More are pages you swipe between (or tap in the bottom bar). "More" is a page of its own with tiles for Resume Watching, Favorites, Duplicates, Unknown, Plugins and Settings; a tap opens that screen on top and Back returns. On a TV the sidebar lists everything and switches the content.
- Dashboard: clock, Continue Watching, Recently Added, Favorites rows (press and hold a widget: Move / Delete; Add widget button)
- Movies, TV Shows: poster grids, Refresh (rescan), movie and show / season / episode pages, Favorite button
- Player: libmpv engine, audio + subtitle pick, buffering, auto-hiding controls, records progress for Resume Watching
- Resume Watching, Favorites (long-press a poster to remove)
- Music: Albums / Artists / Songs, search, album and artist pages, queue, shuffle / repeat, mini player, full-screen player (cover + title on the left, time-synced lyrics on the right, a Browse panel with Queue / Albums / Artists / Songs;
music_fullscreen.dart) - Playlists (
playlists.dartstore per profile,playlist_ui.dart): Music > Playlists tab, playlist page (Play / Shuffle / Add to queue / Rename / Delete, drag to reorder), press-and-hold a song for Play next / Add to queue / Add to playlist, "Add to playlist" on album pages and in the full-screen player, "Save as playlist" for the queue. Not synced to the cloud yet. - Duplicates, Unknown, Plugins (list + remove), Settings (media sources + folder picker)
- Settings > Subtitles (automatic look-up on / off, language drop-down), Video output (match refresh rate, HDR) and Audio output (Dolby / DTS passthrough). Subtitles are also found automatically while a library movie / episode plays, and the player's audio / subtitle menu has a "Find subtitles online" list (
subtitles.dart).
Resume progress, favorites and the dashboard layout are stored on the device with shared_preferences (store.dart).
Files #
flutter_ui/lib/src/api.dart- HTTP client for the existing routesflutter_ui/lib/src/subtitles.dart,settings_extra.dart- online subtitles; Subtitles / Video output / Audio output settings (the last two talk to MpvController over thepek/outputchannel inFlutterHostActivity.kt)flutter_ui/lib/src/mpv.dart- Dart side of the player channels (one shared player for video and music)flutter_ui/lib/src/shell.dart- swipe pages, bottom bar, More page, TV sidebarflutter_ui/lib/src/store.dart- on-device progress, favorites, dashboard layoutflutter_ui/lib/src/{dashboard,music,music_controller,pages,common}.dart- the screens aboveandroid/app/src/main/java/com/pektv/app/MpvController.kt- events can now go to a Flutter sink (webView is optional)android/settings.gradle,android/app/build.gradle- conditional Flutter module include, Flutter maven repo
Added later #
- Pin slots on the More page (
pin_grid.dart): after the fixed buttons come 10 tiles the same size as the others. A pinned plugin shows its icon, name and description; empty ones show a faint plus. Tap / D-pad centre on an empty tile lists the installed plugins to pin; on a pinned one it opens it, and press-and-hold (long press, held centre button, or Menu) offers Open / Remove from pins. Pins are stored byplugin_pins.dartand also still shown in the TV sidebar. - Network folders (SMB / NFS / FTP / WebDAV / DLNA): Settings > Add network folder (
net_folders.dart; the Wi-Fi multicast lock is thesetMulticastcall onpek/host). - Show page: the synopsis is cut to 4 lines with Show more / Show less (
ExpandableText,detail.dart). Tapping an episode opens its page (EpisodeScreen): the episode's still picture, title and the whole synopsis from/api/episode-details(Api.episodeDetails; the library's own .nfo / thumbnail first, then TMDb), with Play / Continue / Start over and Mark as watched. Press and hold an episode row still marks just that one. - Search (title, actor, genre, year;
library_pages.dart) and Watch History (a permanent "finished" record kept inStore), both under More. - Skip Intro / Skip Recap (
skip.dart: file chapters, then the online database) and Up next (last 5 minutes of an episode, 5 s countdown when it ends) in the player. Settings > Skip intro and recap. - Lyrics in the full-screen player (
lyrics.dart, shown bymusic_fullscreen.dart) and Settings > Music > Online art & lyrics.
Added in phase 3 #
Profiles (
profiles.dart,profile_pages.dart): Who's watching? picker, first-time setup, add / edit / delete, 4-digit passcode (hashed), 5 profiles max. Each profile has its own history, resume points, favorites, skip settings and skin. Profiles need a signed-in account.Cloud account and sync (
cloud.dart,account_pages.dart): sign up / sign in with the emailed 6-digit code, forgot password, sign out (asks first), delete account. Watch history, resume points and favorites sync per profile through the existing worker; the profile list syncs too. Favorites are keyed by TMDb id, looked up once through the same TMDb worker the backend uses.Layouts (
layouts.dart): Classic, Top bar, Wall, Compact (menu position and poster size, TV/tablet) plus a poster-size setting that phones also get. Per profile.Torrent search (
torrent_search.dart): search the https sites you list (address with{q}), fetched through the existing/api/torrent/fetchroute, plus an optional Torznab provider; results are filtered and ranked like the web engine.Torrents (
torrent.dart): More > Torrents plays a magnet / .torrent link with a loading screen (peers, speed, progress), a loading card inside the player while it buffers or scrubs, and Resume Watching that restarts the same release (also from other devices).Skins (
skins.dart): PEK Ember (default), Mono, Cream, Night red, Green, Neon, per profile (Settings > Appearance). Skins made or installed are kept per profile and saved to the cloud account with the rest of the profile's data (k|<id>items plus thes|skinsetting, see SKINS.md), so a sign-in puts them back.HDR output and refresh-rate matching were already in Settings > Video output.
Dashboard (
dashboard.dart,store.dart): a grid. Clock and plugin shortcuts are tiles (Small = 1 column, Wide = 2); Weather is a full-width card; Continue Watching, Recently Added and Favorites are full-width poster rows. Press and hold a widget (touch, held centre button, or the Menu key) for Move / Delete (and Make wide / small on tiles). Move: drag the widget up or down and let go on a square to place it (D-pad: Up / Down pick the square, centre places it); Back leaves move mode. The Add widget button adds widgets or resets. Saved per profile in the{id, type, size, folder}shape and synced ass|dashboard; the layout and poster size sync ass|look, the city and unit ass|weather(cloud.dart).Clock and Weather widgets (
weather.dart,dashboard.dart): both are dashboard widgets you can add, remove and reorder. The weather tile shows a big temperature with "condition \u00b7 city" under it. Weather uses Open-Meteo like plugins/weather (pick a city, Fahrenheit or Celsius, last reading shown offline).Clock settings (
clock.dart): tap the Clock tile for 24-hour time, Show date and a time zone list (each zone shows the time it has now). 38 zones with a "day \u00b7 zone" line; kept on the device, not synced. Zones are computed from built-in daylight-saving rules (US/Canada, EU/UK, Sydney, Auckland, Cairo), so there is no extra package; the rules need a look only if a country changes its clocks.Online / offline mode (
net.dart): a 10px dot in the top right corner (green online, red offline), a "Back online" / "Offline mode" notice, and a check every 30 s (8 s while offline) and whenever the app returns to the front. Back online: poster lookups that failed are asked again, the profile syncs and the weather refreshes. The local library works the same either way.
Watched / unwatched #
Movie page: Mark as watched / unwatched. Show page: Mark show ..., Mark season ... and a tick on each watched episode (press and hold an episode to toggle just that one); movie posters show a tick before the year. Everything uses Watch History (Store.setWatched), so the Watch History page, plugins and PEK Cloud agree. A change is pushed to the worker about 2 s later (watched.dart); removals go up as {deleted} markers, which cloud/worker.js already stores for m| and e| keys, so the worker needed no change. Plugins with the watched permission can check / set movies and episodes (see FLUTTER_PLUGINS.md). Plugin movie posters also get the tick and press-and-hold toggle automatically (Settings > Plugin posters).
Background activity line and loading ring #
activity.dart: a small, non-focusable status line bottom right ("Scanning your media folders...", "Syncing watch history...", "Loading your library...", "Searching releases: ...", "Installing ...", "Starting stream: ..."), hidden while a video plays or the keyboard is open, and lifted above the phone's bottom bar and the mini player. Settings > Background activity: Off / Basic / Detailed (Detailed adds plugin loading / starting). Activity.begin / update / end / note / track. The ring (Busy, BusyRing) appears over the screen when a request to the PEK TV server takes more than 0.5 s (poll-style lookups are ignored, same list as the activity line). Not shown: plugin network requests (the Python side makes them, so the app can't see them).
Plugin screens (Python) #
Plugins can ship a Python screen.py that the Flutter UI draws natively (see FLUTTER_PLUGINS.md). TMDb Discover is fully ported (inline trailer, streaming apps, watched, Play with a plugin, synced favorites): Plugins > Open, or its own entry in the menu / More page.
Plugin store #
Plugins > Browse the plugin store, and Settings > Appearance > Browse the skin store: one full-page store for both (store_browser.dart; the same worker, kind=plugin or kind=skin).
- A search box that suggests as you type (names first, then authors: tap a name to open it, tap an author to show only theirs), the Official / Community shelves, a sort (best match, newest, oldest, name A-Z, author A-Z) and an author filter. "Newest" uses the
updatedtime the worker already sends for every item. - Cards show the picture (a skin shows its own colours, drawn small from the skin's file, downloaded once per session), version, author, size, when it was uploaded, and an Installed / Update / New badge.
- Opening a card goes to the same install screens as before: the warning and permissions screens for a plugin (
plugin_store.dart,plugin_store_ui.dart; details inFLUTTER_PLUGINS.md), the colour preview for a skin (skin_install_ui.dart). Nothing installs from the browsing screen. A skin installed this way is remembered as{'type': 'store', 'shelf': ...}and shown as "PEK Store" under its name.
TV mode #
Settings > TV mode (tv_mode.dart): Auto (on for TV boxes, off on phones) / On / Off, and Screen margins (None, Small 2 %, Medium 4 %, Large 6 %). Both belong to the device, not to a profile. The 10-foot look:
- rem follows the screen width (1920 wide = 30 px) and TV-safe margins are applied by
PekScope.safeX / safeY(pages, sidebar, top bar, sub-screen app bar, detail pages). - Bold 0.2rem focus ring plus the 1.07 poster lift (
PekFocusable), 3rem buttons / list rows / text fields, poster grid minimum 9rem (7rem small, 14rem large) so the column count is the same at any resolution. - Material-drawn widgets (dialogs, text fields, icons) follow rem too (
PekRoot). - The mouse pointer hides after 3 idle seconds in TV mode (mouse only, so touch is never blocked).
PekScope.isTvis the look;PekScope.tvDeviceis the hardware andPekScope.sidebarthe layout (sidebar on TV boxes, or wide screens with the look forced on). Forcing TV mode Off on a TV box keeps the sidebar and D-pad behaviour.- Game controllers need no port: Android turns them into D-pad keys, which Flutter's focus system already handles.
Artwork / title cache and the video surface (Fixes 3) #
flutter_ui/lib/src/art_cache.dart: posters (and any other picture shown throughArtImage) are kept on the device (cache folder, about 300 MB, oldest removed first). Synopsis + poster address per title are kept indetails.jsonfor 30 days (internet pictures only), and the last movie / show lists inmovies.json/shows.json(used when the server cannot answer). Pictures from the local server (127.0.0.1) are not copied.- A poster / synopsis lookup that fails is tried again after 2 s and 6 s, and one that still fails (or finds no picture) is forgotten after 20 s (60 s) so the next time the screen is built it asks again; a picture that will not load is retried after 8, 16 and 24 s (
Api._cachedDetails,PosterCard). Refresh is no longer needed to get blank posters back. - Video shows through the transparent Flutter surface. Flutter's Impeller renderer is switched off in
AndroidManifest.xml(io.flutter.embedding.android.EnableImpeller) because its Vulkan path can paint an opaque black frame over the video. ThePEK playback: vo=...line in logcat (MpvController) says whether mpv itself is producing video.
Flutter is the only UI (classic UI fallback removed) #
MainActivity is now only the start-up activity: it copies the app's files and built-in plugins, starts the C++ server and the Python plugins, waits for the server, opens FlutterHostActivity and finishes. No web view, no PekBridge, no pek_classic / web_ui switch, no "Open classic UI" buttons. If the server does not start, or the Flutter screen cannot open, a plain message with a Try again button is shown. SplashOverlay.kt (it drew over the web view) is gone. The old www/ web UI folder has been deleted and the server no longer serves pages: it is API only.
Skin install and skin builder #
- Install skin from .zip / GitHub (
skin_install.dart,skin_install_ui.dart, Settings > Appearance): same checks asbackend/skin_install.h. It reuses the plugin zip reader (PluginZip.read), the zip picker and the GitHub flow (plugin_install_ui.dart, now with askin:switch;PekGithub.downloadBytesis the shared download). Colours and corner size are stored on the device like made skins (idpkg-<slug>); a skin with askin.cssis refused (no CSS in skins, here and inSkinBuilder/tools/make_skin.py). GitHub skins are labelled unverified. The Settings > Plugins and skins from GitHub switch covers both. - The Skin maker is the bundled
skin-makerplugin (plugins/skin-maker);tools/make_skin.pymakes skins and installable .zips from the command line. The app keeps only what the plugin needs drawn (skin_widgets.dart: colour window, preview). See SKINS.md. - Tests:
flutter test test/skin_install_test.dart test/skin_sync_test.dart.
Dashboard widgets (Add widget) #
Add widget lists every widget that is not on the dashboard yet, in groups. flutter_ui/lib/src/dash_widgets.dart holds the newer ones; each loads its own data and keeps itself up to date:
- Music: Now Playing (cover, title, artist, progress, shuffle / previous / play / next / repeat; tap = full-screen player), Playlists and Albums (rows of covers).
- Weather and time: Clock and Weather (one full-width card: the temperature and conditions now, and under them a horizontal, scrollable forecast, the next 24 hours or next 7 days, toggled with Hourly / Daily; tap a day for its details; Open-Meteo, cached for offline). The old separate Forecast widget was merged into Weather: a saved layout that has
forecastis read asweather(Store.cleanDashboard). The Weather widget, the header badge and the Weather plugin share one city and unit, so a city is only typed once. - Movies and shows: Continue Watching, Up Next (the next unwatched episode of each show you are watching), Recently Added, Recently Watched, TV Shows, Favorites, Library (counts tile), Surprise me (random movie tile).
- Plugins: Pinned plugins (a row of the icons pinned on the More page) and a shortcut tile for each installed plugin with a screen.
To add one: write the widget in dash_widgets.dart, add its type to Store.dashTypes and Store.dashGroups (and Store.dashIsTile if it is a small tile), then add a case for it in _DashboardPageState._widget (dashboard.dart). The layout syncs to the profile like before (s|dashboard).
Pictures that heal themselves (no manual refresh) #
Posters used to stay blank until the screen was rebuilt by hand (Refresh). Causes and fixes:
Api._cachedDetailskept a failed or picture-less look-up for the card that asked for it;PosterCard/PosterImage(widgets.dart) now keep their own copy of the look-up and re-ask throughApi.retryDetailsafter 5, 12, 25, 45, 60 and 60 s (library posters, dashboard rows, detail pages).ArtCachetreated 403 / 429 as final ("not there"), so a rate-limited picture was never asked for again this run. Only 404 / 410 are final now.ArtImagekeeps asking after the quick tries (every 30 s, 10 more times) and swaps the picture in when it arrives; plugin posters (ui.poster_card,ui.episode_row, provider logos, which now also useArtImage) rely on this because their screens only redraw when the plugin does.Net._backOnlinecallsArtCache.retryAll(): every picture and poster that gave up tries again at once.- Plugins:
api()intmdb-discover(1.7.1) andsnake-skin/ Stream Media (3.4.1) retries a failed TMDb request inside its background job.
Files that will not open #
MpvController tells Dart why a load ended: ended (it played) or error (it never loaded: damaged, 0 bytes, unsupported). The mpv event channel is now {event: time|state|tracks|ended|error, data: {...}}. PekPlayer.failed (mpv.dart) is the Dart side; music_controller.dart marks the song unplayable and skips it, music.dart (TrackRow) greys it out, and player.dart closes a video that will not open. Only when libmpv itself cannot start does the host still show "Player has crashed - please restart" (and send ended). Details and limits: MUSIC.md > "Files that cannot be played".
Music home-screen widget (Android) #
android/app/src/main/java/com/pektv/app/MusicWidget.kt plus the widget resources (layout, provider info, drawables, ember_* colours, strings) and a receiver in AndroidManifest.xml. It is native only: no Flutter screen and no channel. MusicService pushes MusicState to it whenever it redraws the notification. Details: MUSIC.md > "Home-screen widget".