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.
| Feature | What it is | Status |
|---|---|---|
| Timeline | the date-ordered, scrubbable grid | in progress |
| Albums, Collections | a scope — {:album, id} | planned |
| People, Pets | a scope over detected subjects | planned |
| Map | a view over the same assets | planned |
| Memories | queries over capture date | planned |
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"}
]
endNo 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
| Widget | Job |
|---|---|
PhoenixKitWeb.Components.MediaBrowser | File work: folders, ingest, search |
PhoenixKitWeb.Components.MediaGallery | Picking and ordering images for a form |
PhoenixKitPhotos.Components.PhotoTimeline | A 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 areYYYY-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):
| Viewer | What 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:
| Engine | What it is |
|---|---|
photoswipe (default) | PhotoSwipe: gestures, zoom, the fly to and from the tile |
snap | The 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.
| To | Do |
|---|---|
| Start | The circle on a tile, Ctrl/Cmd + click, or the Select button |
| Take a run, across months | Shift + click |
| Draw a box | Drag with the mouse; the page scrolls at the top and bottom edges |
| On a touch screen | Hold a tile, then slide over others without letting go |
| Take a month | The circle on its heading |
| Take this moment, this day, every video, everything | The Select menu of the bar; Ctrl/Cmd + A for everything |
| Let go | Escape |
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.
| Action | Needs | What happens |
|---|---|---|
| Download | read | A ZIP of the originals, streamed as they are read (Web.Download, Zip) |
| Copy links | read | The photos' addresses, ready to paste |
| Add to library | read, and upload in the target | Shares the stored objects where it can, stores again where it cannot; skips what the target holds |
| Move | edit, and upload in the target | The same, then the original to the trash |
| Tags | edit | Adds to and removes from metadata["tags"] |
| Trash | edit | With 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.
| Tool | Scope | Does |
|---|---|---|
photos.list_libraries | photos.files:read | The libraries the owner can open, and which they may add to |
photos.find_files | photos.files:read | By capture date, kind, tag or file name, paged |
photos.trash_files, photos.restore_files | photos.files:write | To the trash and back |
photos.copy_files, photos.move_files | photos.files:write | To another library |
photos.tag_files | photos.files:write | Add 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