A photo and video library for PhoenixKit, in the spirit of Apple Photos and Google Photos.

Not a gallery. The target is a person opening their entire library spanning decades — grabbing the scrubber, jumping to March 2016, and not killing the tab at 200,000 files.

Scope

The timeline comes first, and it is the spine rather than one feature among many: nearly everything else is a scope or a view over the same date-ordered, virtualized engine.

FeatureWhat it isStatus
Timelinethe date-ordered, scrubbable gridin progress
Albums, Collectionsa scope — {:album, id}planned
People, Petsa scope over detected subjectsplanned
Mapa view over the same assetsplanned
Memoriesqueries over capture dateplanned

If this were only the grid, it would belong in PhoenixKit core as a MediaBrowser view mode. It is a separate package because of everything after the first row.

Status: Stage 1, square grid

The timeline reads one storage library ({:library, uuid}), grouped by the local capture date taken_on. PhoenixKit 2.41.0 already stores that date and the library partition; this package does not add either.

The 10k gate from the plan — a square library of 10k photos, a jump to a random month, in Chrome and Safari, with a bounded DOM count — is measured in Fotki, the host. Automated Chromium checks now cover a synthetic 10k grid and a signed-in Fotki page, including month jumps, large-day tails, viewer return, mobile sizing, and library switching. Images in those tests are synthetic placeholders. The full Chrome/Safari gate with representative media has not passed, so Stage 1 is not claimed as shipped.

See dev_docs/plans/2026-09-20-phoenix-kit-media-timeline.md for the design (written under the package's original name), dev_docs/reviews/ for the review it incorporates, and dev_docs/plans/2026-09-21-phoenix-kit-photos.md for the rename and the broader scope.

Installation

def deps do
  [
    {:phoenix_kit, "~> 3.5"},
    {:phoenix_kit_photos, "~> 0.3"}
  ]
end

No configuration. PhoenixKit.ModuleDiscovery finds any dependency that depends on :phoenix_kit and uses PhoenixKit.Module, by scanning beam attributes.

During development, path-dep it instead:

{:phoenix_kit_photos, path: "../phoenix_kit_photos"}

Three widgets, never to be conflated

WidgetJob
PhoenixKitWeb.Components.MediaBrowserFile work: folders, ingest, search
PhoenixKitWeb.Components.MediaGalleryPicking and ordering images for a form
PhoenixKitPhotos.Components.PhotoTimelineA library ordered by capture date

Usage

Subscribe in the parent LiveView (use your component's ID):

on_mount {PhoenixKitPhotos.LiveUpdates, "library"}
<.live_component
  module={PhoenixKitPhotos.Components.PhotoTimeline}
  id="library"
  scope={{:library, library_uuid}}
  auth={scope}
  layout={:square}
  columns={5}
  on_open="open_asset"
/>

library_uuid is one storage library. PhoenixKitPhotos.Timeline.libraries/1 lists Media plus the user's owned and shared libraries, and default_library_uuid/1 picks the one to open. Media is enough to measure the grid and does not require user libraries to be enabled.

The supplied page and Fotki open PhoenixKit's existing canvas viewer in a read-only dialog. ?at=<uuid> identifies the open file; closing patches the URL and keeps the timeline mounted at the same position. Custom on_open handlers must reauthorize the UUID with Timeline.viewer_file/3.

The timeline reads one library. Albums, shares and the scrubber are later stages.

Architecture

LiveView owns permissions, window transport and PubSub. The JS hook owns geometry, DOM recycling and the scrubber. The seam between them is two JSON shapes:

  • index — monthly buckets with counts, the whole library in a few hundred entries. This is what lets the scrollbar be a map of time.
  • window — a day or a slice of a large day, capped at 500 items. The first slice is YYYY-MM-DD; later slices are YYYY-MM-DD:500, :1000, etc. Counts and geometry include all slices, so no photo is silently truncated.

Window requests target their component, and replies are namespaced by component ID and index revision. Refreshes invalidate the cache; stale responses are ignored. The hook retains only visible and adjacent windows. Tiles fit the container width and resizing preserves the same row.

assets/js/geometry.js is pure math with no DOM and no framework, and is unit tested (cd assets && npm test).

The viewer of a library

Each library chooses how its photos open (PhoenixKitPhotos.Viewer):

ViewerWhat it is
Swipe viewer ("photoswipe", the default)Swipe between photos and videos, pinch and double-tap zoom, drag down to close, the fly from and to the tile; PhotoSwipe 5 under it
Classic viewer ("classic")PhoenixKit's own media viewer (MediaCanvasViewer): one file at a time, with its sidebar, comments and annotations

The choice is the library's own setting, this package's (Libraries.put_module_setting(library, "photos", "viewer", name): core keeps it in the library's settings under the package's namespace and knows nothing of it), so it follows the library to every browser. A library that has not chosen follows config :phoenix_kit_photos, default_viewer: "classic" | "photoswipe" ("photoswipe" when unset). Components.ViewerPicker is the select for it: it shows nothing to someone who may not choose (a user library's owner and managers; for Media, the site's admins), and tells its page {:viewer_changed, library_uuid}. The page passes Viewer.pager(Viewer.for_library(library)) as PhotoTimeline's pager. In the swipe viewer, an Info button opens the classic viewer on the file on screen, so details, comments and annotations are one tap away.

The swipe viewer's engines

PhotoTimeline's pager is false (the host's own viewer, on on_open), true (the default engine) or an engine's name. The engines are interchangeable behind one contract (assets/js/viewer/engine.js), so a library can be swapped without touching the timeline:

EngineWhat it is
photoswipe (default)PhotoSwipe: gestures, zoom, the fly to and from the tile
snapThe browser's own scroll-snap: native swipe feel; no zoom, a fade instead of the fly, no drag to close. Registered, but not one a library can choose: a baseline to compare with

The timeline owns the data (the index, which slides are loaded, what to ask for next, the grid and scrolling it to the file on screen); an engine owns gestures, transitions and its DOM. Videos are built by the shared VideoController (poster first, nothing fetched until played, the file chosen for the screen, HDR kept where it can be shown, the next file tried when one fails), so they behave alike in every engine. A name the page does not know falls back to the default engine. To add one: write a class with the contract's methods, register it in viewer/engines.js, add its name to PhoenixKitPhotos.Viewer to offer it, and rebuild the bundle.

Styles

The timeline shows a library in ten styles, picked from the menu by the month pill (styles) or with v: Grid, Rows, Columns, Mosaic, Uncropped, Feed, Prints, Film, Honeycomb and Details. Pinch and +/- zoom each of them. A style is a shape (assets/js/layouts.js, pure math) and a look (assets/js/timeline_css.js), so adding one is a function and a few lines of CSS. The pick is kept in the browser and wins over the host's layout.

The heights stay exact in every style. Jump to month, the bookmark and the viewer's way back all need to know where a photo is before its window has been fetched. Square tiles get that from the count. Whole-photo styles get it from Timeline.aspects/2: one byte of aspect ratio per file, asked for the first time such a style is picked (260 KB for 200,000 files), so nothing is measured or corrected while scrolling. A refresh sends only the months that changed.

The change between styles (assets/js/morph.js) keeps the photo in the middle of the screen in place and moves every other tile from where it was to where it belongs, in a wave from the middle. Width and height are animated rather than a scale, so object-fit: cover recrops at every step and a square opens up into the whole picture. Past 260 tiles on screen they travel as transforms instead. prefers-reduced-motion turns it off.

Choosing many

With selectable, a person chooses photos and acts on them from a bar along the bottom.

ToDo
StartThe circle on a tile, Ctrl/Cmd + click, or the Select button
Take a run, across monthsShift + click
Draw a boxDrag with the mouse; the page scrolls at the top and bottom edges
On a touch screenHold a tile, then slide over others without letting go
Take a monthThe circle on its heading
Take this moment, this day, every video, everythingThe Select menu of the bar; Ctrl/Cmd + A for everything
Let goEscape

What is chosen is a set of ids in the browser (assets/js/selection.js); a run through months not yet fetched is resolved by the server (Timeline.ids_between/3). A selection holds at most 5,000.

Every action goes through PhoenixKitPhotos.Bulk, in two steps: plan/4 checks each file for the person and writes nothing, and run/2 does what the plan says. The dialog shows the plan; saying yes plans again, so rights lost in the meantime count. Nothing deletes for good.

ActionNeedsWhat happens
DownloadreadA ZIP of the originals, streamed as they are read (Web.Download, Zip)
Copy linksreadThe photos' addresses, ready to paste
Add to libraryread, and upload in the targetShares the stored objects where it can, stores again where it cannot; skips what the target holds
Moveedit, and upload in the targetThe same, then the original to the trash
TagseditAdds to and removes from metadata["tags"]
TrasheditWith an Undo in the notice that follows

A host mounts the download in a pipeline with the session and CSRF protection, and says where:

post "/photos/download", PhoenixKitPhotos.Web.Download, []
<.live_component module={PhotoTimeline} id="library" scope={...} auth={scope}
  styles selectable download_path="/photos/download" />

For agents

api_tools/0 declares the library for the machine API (PhoenixKitPhotos.API): find what a person would ask for, then act on it. A key acts as its owner, file by file. Write tools are Bulk with an API around it, so each answers a dry run with what would happen, and a confirmation or an approval fails when the files changed since.

ToolScopeDoes
photos.list_librariesphotos.files:readThe libraries the owner can open, and which they may add to
photos.find_filesphotos.files:readBy capture date, kind, tag or file name, paged
photos.trash_files, photos.restore_filesphotos.files:writeTo the trash and back
photos.copy_files, photos.move_filesphotos.files:writeTo another library
photos.tag_filesphotos.files:writeAdd and remove tags

A batch holds at most 1,000 ids. Over api_confirm_batch_over (50 by default) core treats a write as dangerous and asks for a confirmation.

Core media viewer: warming originals

Core's media viewer is deliberately plain: it warms only each neighbour's small and large, once the current picture has settled. Whether a multi-MB original is worth fetching ahead of an arrow press is high-end viewing, so it is decided here (assets/js/viewer_warm.js). Core announces pk:viewer-neighbours and this bundle answers: only where large (1920 px) will not do, judged on the width the picture is actually displayed at (its aspect fitted in the viewer box, times the pixel ratio — a portrait photo in a wide window is narrow), one original (the neighbour in the direction last stepped, the next before any step), low priority, never on data-saver or a 2G/3G line. Choosing a better file by real display class (5K and up) and window size belongs in the same file.

Development

mix deps.get
mix assets.build    # rebuilds priv/static/assets/phoenix_kit_photos.js
MIX_ENV=test mix test   # PG* / DB_* must point at a migrated, sandboxable DB
cd assets && npm test

js_sources/0 declares a prebuilt bundle — it does not compile one. Run mix assets.build after touching anything in assets/js/, and commit the output: it ships in the Hex package.

The SQL suite does not create or migrate a database. Each case uses SQL Sandbox; never add fixtures that commit. test/support/synthetic_library.ex provides 10k/100k fixtures for a caller-owned sandbox transaction.

The standalone DOM regression needs Playwright and its Chromium browser:

cd assets
node test/browser.mjs

Set PLAYWRIGHT_MODULE to an installed Playwright module when it is not a local dependency, and PLAYWRIGHT_BROWSERS_PATH when using a shared browser installation. Fotki's scripts/photos_browser_host.exs and test/browser/photos.mjs contain the authenticated host check. Month msgids are extracted into priv/gettext/default.pot; translations can be added with the standard Gettext tools.

License

MIT