BlueSky & more on desktop lazurite.stormlightlabs.org/
tauri rust typescript bluesky appview atproto solid
2
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: add ImageGallery and VideoEmbed components with downloads

+1075 -48
+19 -19
docs/tasks/15-media.md
··· 19 19 20 20 ### Frontend - Video Player (`src/components/feeds/VideoEmbed.tsx`) 21 21 22 - - [ ] `VideoEmbed` component: `<video>` element with poster from `thumbnail`, native controls 23 - - [ ] Lazy-load HLS.js — attach to video element only when `playlist` URL is m3u8 24 - - [ ] Click-to-play: show thumbnail + centered play button overlay, start playback on click 25 - - [ ] Respect `aspectRatio` from embed to prevent layout shift 26 - - [ ] Render `alt` text as caption below player when present 27 - - [ ] Replace `ExternalEmbed` fallback in `EmbedContent` switch for `app.bsky.embed.video#view` 28 - - [ ] Download button in player controls area → invoke `download_video` command 22 + - [x] `VideoEmbed` component: `<video>` element with poster from `thumbnail`, native controls 23 + - [x] Lazy-load HLS.js — attach to video element only when `playlist` URL is m3u8 24 + - [x] Click-to-play: show thumbnail + centered play button overlay, start playback on click 25 + - [x] Respect `aspectRatio` from embed to prevent layout shift 26 + - [x] Render `alt` text as caption below player when present 27 + - [x] Replace `ExternalEmbed` fallback in `EmbedContent` switch for `app.bsky.embed.video#view` 28 + - [x] Download button in player controls area → invoke `download_video` command 29 29 30 30 ### Frontend - Image Gallery (`src/components/feeds/ImageGallery.tsx`) 31 31 32 - - [ ] Gallery overlay: glass background (`surface_container_highest` 70% + backdrop-blur 20px) 33 - - [ ] Display `fullsize` image with `object-contain`, constrained to viewport 34 - - [ ] `Presence` fade-in/fade-out transitions 35 - - [ ] Left/right navigation arrows + position indicator for multi-image posts 36 - - [ ] Keyboard: `Escape` close, `ArrowLeft`/`ArrowRight` navigate 37 - - [ ] Caption panel: alt text (`body-md`), post text truncated to 2 lines with expand, author handle as link 38 - - [ ] Download button in gallery toolbar → invoke `download_image` command 39 - - [ ] Wire `ImageEmbed` click handler to open gallery at the clicked image index 32 + - [x] Gallery overlay: glass background (`surface_container_highest` 70% + backdrop-blur 20px) 33 + - [x] Display `fullsize` image with `object-contain`, constrained to viewport 34 + - [x] `Presence` fade-in/fade-out transitions 35 + - [x] Left/right navigation arrows + position indicator for multi-image posts 36 + - [x] Keyboard: `Escape` close, `ArrowLeft`/`ArrowRight` navigate 37 + - [x] Caption panel: alt text (`body-md`), post text truncated to 2 lines with expand, author handle as link 38 + - [x] Download button in gallery toolbar → invoke `download_image` command 39 + - [x] Wire `ImageEmbed` click handler to open gallery at the clicked image index 40 40 41 41 ### Frontend - Download UX 42 42 43 - - [ ] Download button spinner/progress indicator while active 44 - - [ ] Success toast: filename + "Open in Finder" action (via `tauri-plugin-opener`) 45 - - [ ] Error toast: human-readable failure message 46 - - [ ] Right-click context menu on inline images with "Save image" option 43 + - [x] Download button spinner/progress indicator while active 44 + - [x] Success toast: filename + "Open in Finder" action (via `tauri-plugin-opener`) 45 + - [x] Error toast: human-readable failure message 46 + - [x] Right-click context menu on inline images with "Save image" option 47 47 48 48 ### Frontend - Settings Integration 49 49
+1
package.json
··· 24 24 "@tauri-apps/plugin-log": "~2", 25 25 "@tauri-apps/plugin-notification": "~2.3.3", 26 26 "@tauri-apps/plugin-opener": "^2", 27 + "hls.js": "^1.6.15", 27 28 "solid-js": "^1.9.3", 28 29 "solid-motionone": "^1.0.4" 29 30 },
+8
pnpm-lock.yaml
··· 29 29 '@tauri-apps/plugin-opener': 30 30 specifier: ^2 31 31 version: 2.5.3 32 + hls.js: 33 + specifier: ^1.6.15 34 + version: 1.6.15 32 35 solid-js: 33 36 specifier: ^1.9.3 34 37 version: 1.9.12 ··· 1572 1575 1573 1576 hey-listen@1.0.8: 1574 1577 resolution: {integrity: sha512-COpmrF2NOg4TBWUJ5UVyaCU2A88wEMkUPK4hNqyCkqHbxT92BbvfjoSozkAIIm6XhicGlJHhFdullInrdhwU8Q==} 1578 + 1579 + hls.js@1.6.15: 1580 + resolution: {integrity: sha512-E3a5VwgXimGHwpRGV+WxRTKeSp2DW5DI5MWv34ulL3t5UNmyJWCQ1KmLEHbYzcfThfXG8amBL+fCYPneGHC4VA==} 1575 1581 1576 1582 html-encoding-sniffer@6.0.0: 1577 1583 resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==} ··· 4294 4300 function-bind: 1.1.2 4295 4301 4296 4302 hey-listen@1.0.8: {} 4303 + 4304 + hls.js@1.6.15: {} 4297 4305 4298 4306 html-encoding-sniffer@6.0.0: 4299 4307 dependencies:
+71
src/components/feeds/ImageGallery.test.tsx
··· 1 + import { fireEvent, render, screen, waitFor } from "@solidjs/testing-library"; 2 + import { beforeEach, describe, expect, it, vi } from "vitest"; 3 + import { ImageGallery } from "./ImageGallery"; 4 + 5 + const downloadImageMock = vi.hoisted(() => vi.fn()); 6 + const revealItemInDirMock = vi.hoisted(() => vi.fn()); 7 + 8 + vi.mock("$/lib/api/media", () => ({ downloadImage: downloadImageMock })); 9 + vi.mock("@tauri-apps/plugin-opener", () => ({ revealItemInDir: revealItemInDirMock })); 10 + 11 + const GALLERY_IMAGES = [{ alt: "First image", fullsize: "https://cdn.example.com/first.jpg" }, { 12 + alt: "Second image", 13 + fullsize: "https://cdn.example.com/second.jpg", 14 + }] as const; 15 + 16 + describe("ImageGallery", () => { 17 + beforeEach(() => { 18 + downloadImageMock.mockReset(); 19 + revealItemInDirMock.mockReset(); 20 + revealItemInDirMock.mockResolvedValue(void 0); 21 + }); 22 + 23 + it("supports keyboard navigation and escape close", async () => { 24 + const onClose = vi.fn(); 25 + render(() => ( 26 + <ImageGallery images={[...GALLERY_IMAGES]} open postText="Post text" startIndex={0} onClose={onClose} /> 27 + )); 28 + 29 + expect(screen.getByText("1 / 2")).toBeInTheDocument(); 30 + expect(screen.getByAltText("First image")).toBeInTheDocument(); 31 + 32 + fireEvent.keyDown(globalThis as unknown as Window, { key: "ArrowRight" }); 33 + await waitFor(() => expect(screen.getByAltText("Second image")).toBeInTheDocument()); 34 + 35 + fireEvent.keyDown(globalThis as unknown as Window, { key: "Escape" }); 36 + expect(onClose).toHaveBeenCalledTimes(1); 37 + }); 38 + 39 + it("truncates post copy and toggles expansion", () => { 40 + render(() => ( 41 + <ImageGallery images={[...GALLERY_IMAGES]} open postText={"x".repeat(220)} startIndex={0} onClose={() => {}} /> 42 + )); 43 + 44 + const toggle = screen.getByRole("button", { name: "Show more" }); 45 + fireEvent.click(toggle); 46 + expect(screen.getByRole("button", { name: "Show less" })).toBeInTheDocument(); 47 + }); 48 + 49 + it("downloads the selected image and reveals it in Finder", async () => { 50 + downloadImageMock.mockResolvedValue({ bytes: 1200, path: "/tmp/gallery.jpg" }); 51 + 52 + render(() => ( 53 + <ImageGallery 54 + authorHandle="@alice.test" 55 + authorHref="/profile/alice.test" 56 + images={[...GALLERY_IMAGES]} 57 + open 58 + postText="Gallery post" 59 + startIndex={0} 60 + onClose={() => {}} /> 61 + )); 62 + 63 + fireEvent.click(screen.getByRole("button", { name: "Download image" })); 64 + 65 + await waitFor(() => expect(downloadImageMock).toHaveBeenCalledWith("https://cdn.example.com/first.jpg")); 66 + expect(await screen.findByText("Saved gallery.jpg.")).toBeInTheDocument(); 67 + 68 + fireEvent.click(screen.getByRole("button", { name: "Open in Finder" })); 69 + await waitFor(() => expect(revealItemInDirMock).toHaveBeenCalledWith("/tmp/gallery.jpg")); 70 + }); 71 + });
+329
src/components/feeds/ImageGallery.tsx
··· 1 + import { type MediaNotice, MediaNoticeToast } from "$/components/feeds/MediaNoticeToast"; 2 + import { Icon } from "$/components/shared/Icon"; 3 + import { downloadImage } from "$/lib/api/media"; 4 + import { clamp, normalizeError } from "$/lib/utils/text"; 5 + import { revealItemInDir } from "@tauri-apps/plugin-opener"; 6 + import { createEffect, createMemo, createSignal, onCleanup, Show } from "solid-js"; 7 + import { Portal } from "solid-js/web"; 8 + import { Motion, Presence } from "solid-motionone"; 9 + 10 + type GalleryImage = { alt?: string; fullsize?: string; thumb?: string }; 11 + 12 + type ImageGalleryProps = { 13 + authorHandle?: string; 14 + authorHref?: string; 15 + images: GalleryImage[]; 16 + open: boolean; 17 + postText?: string; 18 + startIndex: number; 19 + onClose: () => void; 20 + }; 21 + 22 + export function ImageGallery(props: ImageGalleryProps) { 23 + const [index, setIndex] = createSignal(0); 24 + const [expanded, setExpanded] = createSignal(false); 25 + const [downloadPending, setDownloadPending] = createSignal(false); 26 + const [notice, setNotice] = createSignal<MediaNotice | null>(null); 27 + 28 + let noticeTimer: ReturnType<typeof setTimeout> | null = null; 29 + const imageCount = createMemo(() => props.images.length); 30 + const hasManyImages = createMemo(() => imageCount() > 1); 31 + const selectedImage = createMemo(() => props.images[index()] ?? null); 32 + const currentImageUrl = createMemo(() => selectedImage()?.fullsize ?? selectedImage()?.thumb ?? null); 33 + const showPostTextToggle = createMemo(() => (props.postText ?? "").trim().length > 140); 34 + 35 + createEffect(() => { 36 + if (!props.open) { 37 + return; 38 + } 39 + 40 + const clamped = clamp(props.startIndex, 0, Math.max(imageCount() - 1, 0)); 41 + setIndex(clamped); 42 + setExpanded(false); 43 + }); 44 + 45 + createEffect(() => { 46 + if (!props.open) { 47 + return; 48 + } 49 + 50 + const handleKeyDown = (event: KeyboardEvent) => { 51 + switch (event.key) { 52 + case "Escape": { 53 + event.preventDefault(); 54 + props.onClose(); 55 + break; 56 + } 57 + case "ArrowLeft": { 58 + if (!hasManyImages()) { 59 + break; 60 + } 61 + 62 + event.preventDefault(); 63 + setIndex((current) => (current - 1 + imageCount()) % imageCount()); 64 + break; 65 + } 66 + case "ArrowRight": { 67 + if (!hasManyImages()) { 68 + break; 69 + } 70 + 71 + event.preventDefault(); 72 + setIndex((current) => (current + 1) % imageCount()); 73 + break; 74 + } 75 + default: { 76 + break; 77 + } 78 + } 79 + }; 80 + 81 + globalThis.addEventListener("keydown", handleKeyDown); 82 + onCleanup(() => globalThis.removeEventListener("keydown", handleKeyDown)); 83 + }); 84 + 85 + onCleanup(() => { 86 + if (noticeTimer !== null) { 87 + clearTimeout(noticeTimer); 88 + } 89 + }); 90 + 91 + function dismissNotice() { 92 + setNotice(null); 93 + if (noticeTimer !== null) { 94 + clearTimeout(noticeTimer); 95 + noticeTimer = null; 96 + } 97 + } 98 + 99 + function queueNotice(next: MediaNotice) { 100 + dismissNotice(); 101 + setNotice(next); 102 + noticeTimer = setTimeout(() => { 103 + setNotice(null); 104 + noticeTimer = null; 105 + }, 6000); 106 + } 107 + 108 + async function downloadCurrentImage() { 109 + const currentImage = currentImageUrl(); 110 + if (!currentImage || downloadPending()) { 111 + return; 112 + } 113 + 114 + setDownloadPending(true); 115 + try { 116 + const result = await downloadImage(currentImage); 117 + queueNotice({ kind: "success", message: `Saved ${filenameFromPath(result.path)}.`, path: result.path }); 118 + } catch (error) { 119 + queueNotice({ kind: "error", message: toDownloadErrorMessage(error) }); 120 + } finally { 121 + setDownloadPending(false); 122 + } 123 + } 124 + 125 + function step(offset: -1 | 1) { 126 + if (!hasManyImages()) { 127 + return; 128 + } 129 + 130 + setIndex((current) => (current + offset + imageCount()) % imageCount()); 131 + } 132 + 133 + return ( 134 + <Portal> 135 + <Presence> 136 + <Show when={props.open}> 137 + <GalleryOverlay 138 + authorHandle={props.authorHandle} 139 + authorHref={props.authorHref} 140 + downloadPending={downloadPending()} 141 + expanded={expanded()} 142 + hasManyImages={hasManyImages()} 143 + imageCount={imageCount()} 144 + index={index()} 145 + postText={props.postText} 146 + selectedImage={selectedImage()} 147 + showPostTextToggle={showPostTextToggle()} 148 + onClose={props.onClose} 149 + onDownload={() => void downloadCurrentImage()} 150 + onStep={step} 151 + onToggleExpand={() => setExpanded((current) => !current)} /> 152 + </Show> 153 + </Presence> 154 + 155 + <MediaNoticeToast notice={notice()} onDismiss={dismissNotice} onOpenPath={revealItemInDir} /> 156 + </Portal> 157 + ); 158 + } 159 + 160 + function GalleryOverlay( 161 + props: { 162 + authorHandle?: string; 163 + authorHref?: string; 164 + downloadPending: boolean; 165 + expanded: boolean; 166 + hasManyImages: boolean; 167 + imageCount: number; 168 + index: number; 169 + postText?: string; 170 + selectedImage: GalleryImage | null; 171 + showPostTextToggle: boolean; 172 + onClose: () => void; 173 + onDownload: () => void; 174 + onStep: (offset: -1 | 1) => void; 175 + onToggleExpand: () => void; 176 + }, 177 + ) { 178 + return ( 179 + <Motion.div 180 + class="fixed inset-0 z-60 grid min-h-0 grid-rows-[1fr_auto] bg-surface-container-highest/70 p-4 backdrop-blur-[20px] max-[760px]:p-3" 181 + initial={{ opacity: 0 }} 182 + animate={{ opacity: 1 }} 183 + exit={{ opacity: 0 }} 184 + transition={{ duration: 0.18 }}> 185 + <button 186 + type="button" 187 + aria-label="Close gallery" 188 + class="absolute inset-0 border-0 bg-transparent" 189 + onClick={() => props.onClose()} /> 190 + 191 + <div class="relative z-1 grid min-h-0"> 192 + <Toolbar 193 + current={props.hasManyImages ? props.index + 1 : 1} 194 + disabled={props.downloadPending} 195 + total={props.hasManyImages ? props.imageCount : 1} 196 + onDownload={props.onDownload} 197 + onClose={props.onClose} 198 + pending={props.downloadPending} /> 199 + 200 + <div class="relative grid min-h-0 place-items-center px-14 py-3 max-[760px]:px-11"> 201 + <img 202 + class="max-h-full max-w-full rounded-2xl object-contain shadow-[0_30px_60px_rgba(0,0,0,0.35)]" 203 + src={props.selectedImage?.fullsize ?? props.selectedImage?.thumb} 204 + alt={props.selectedImage?.alt ?? ""} /> 205 + 206 + {props.hasManyImages ? <ArrowButton direction="left" onClick={() => props.onStep(-1)} /> : null} 207 + {props.hasManyImages ? <ArrowButton direction="right" onClick={() => props.onStep(1)} /> : null} 208 + </div> 209 + </div> 210 + 211 + <CaptionPanel 212 + alt={props.selectedImage?.alt} 213 + authorHandle={props.authorHandle} 214 + authorHref={props.authorHref} 215 + expanded={props.expanded} 216 + postText={props.postText} 217 + showToggle={props.showPostTextToggle} 218 + onToggleExpand={props.onToggleExpand} /> 219 + </Motion.div> 220 + ); 221 + } 222 + 223 + function Toolbar( 224 + props: { 225 + current: number; 226 + disabled: boolean; 227 + total: number; 228 + pending: boolean; 229 + onDownload: () => void; 230 + onClose: () => void; 231 + }, 232 + ) { 233 + return ( 234 + <div class="flex min-h-10 items-center justify-between gap-3"> 235 + <p class="m-0 text-xs uppercase tracking-[0.12em] text-on-surface-variant">{props.current} / {props.total}</p> 236 + <div class="flex items-center gap-2"> 237 + <button 238 + type="button" 239 + disabled={props.disabled} 240 + class="inline-flex items-center gap-1.5 rounded-full border-0 bg-surface-container-high px-3 py-1.5 text-xs text-on-surface transition duration-150 ease-out hover:bg-surface-bright disabled:cursor-wait disabled:opacity-65" 241 + aria-label="Download image" 242 + onClick={() => props.onDownload()}> 243 + <Icon 244 + aria-hidden="true" 245 + iconClass={props.pending ? "i-ri-loader-4-line animate-spin" : "i-ri-download-2-line"} /> 246 + <span>{props.pending ? "Saving..." : "Download"}</span> 247 + </button> 248 + <button 249 + type="button" 250 + class="inline-flex h-9 w-9 items-center justify-center rounded-full border-0 bg-surface-container-high text-on-surface-variant transition hover:bg-surface-bright hover:text-on-surface" 251 + aria-label="Close gallery" 252 + onClick={() => props.onClose()}> 253 + <Icon aria-hidden="true" iconClass="i-ri-close-line" /> 254 + </button> 255 + </div> 256 + </div> 257 + ); 258 + } 259 + 260 + function ArrowButton(props: { direction: "left" | "right"; onClick: () => void }) { 261 + return ( 262 + <button 263 + type="button" 264 + class="absolute top-1/2 z-2 inline-flex h-11 w-11 -translate-y-1/2 items-center justify-center rounded-full border-0 bg-surface-container-high/88 text-on-surface transition hover:bg-surface-container-highest" 265 + classList={{ "left-1": props.direction === "left", "right-1": props.direction === "right" }} 266 + aria-label={props.direction === "left" ? "Previous image" : "Next image"} 267 + onClick={() => props.onClick()}> 268 + <Icon 269 + aria-hidden="true" 270 + iconClass={props.direction === "left" ? "i-ri-arrow-left-s-line" : "i-ri-arrow-right-s-line"} /> 271 + </button> 272 + ); 273 + } 274 + 275 + function CaptionPanel( 276 + props: { 277 + alt?: string; 278 + authorHandle?: string; 279 + authorHref?: string; 280 + expanded: boolean; 281 + postText?: string; 282 + showToggle: boolean; 283 + onToggleExpand: () => void; 284 + }, 285 + ) { 286 + const label = () => props.expanded ? "Show less" : "Show more"; 287 + return ( 288 + <div class="relative z-1 grid gap-2 rounded-2xl bg-surface-container-high/86 p-4 shadow-[inset_0_0_0_1px_rgba(255,255,255,0.05)]"> 289 + <Show when={props.alt}>{(alt) => <p class="m-0 text-sm leading-normal text-on-surface">{alt()}</p>}</Show> 290 + <Show when={(props.postText ?? "").trim().length > 0}> 291 + <div class="grid items-start gap-1"> 292 + <p class="m-0 text-xs leading-normal text-on-surface-variant" classList={{ "line-clamp-2": !props.expanded }}> 293 + {props.postText} 294 + </p> 295 + <Show when={props.showToggle}> 296 + <button 297 + type="button" 298 + class="justify-self-start border-0 bg-transparent p-0 text-xs text-primary transition hover:text-on-surface" 299 + onClick={() => props.onToggleExpand()}> 300 + {label()} 301 + </button> 302 + </Show> 303 + </div> 304 + </Show> 305 + <Show when={props.authorHandle && props.authorHref}> 306 + <a 307 + class="justify-self-start text-xs text-primary no-underline transition hover:text-on-surface" 308 + href={`#${props.authorHref}`} 309 + title={props.authorHandle}> 310 + {props.authorHandle} 311 + </a> 312 + </Show> 313 + </div> 314 + ); 315 + } 316 + 317 + function filenameFromPath(path: string) { 318 + const parts = path.split(/[/\\]/u); 319 + return parts.at(-1) || "downloaded file"; 320 + } 321 + 322 + function toDownloadErrorMessage(error: unknown) { 323 + const message = normalizeError(error); 324 + if (/download folder|writable|save|directory|exists/iu.test(message)) { 325 + return "Couldn't save — check that the download folder exists."; 326 + } 327 + 328 + return "Couldn't save this image right now."; 329 + }
+59
src/components/feeds/MediaNoticeToast.tsx
··· 1 + import { Show } from "solid-js"; 2 + import { Motion, Presence } from "solid-motionone"; 3 + import { Icon } from "../shared/Icon"; 4 + 5 + export type MediaNotice = { kind: "error"; message: string } | { kind: "success"; message: string; path: string }; 6 + 7 + type MediaNoticeToastProps = { 8 + notice: MediaNotice | null; 9 + onDismiss: () => void; 10 + onOpenPath?: (path: string) => Promise<void> | void; 11 + }; 12 + 13 + export function MediaNoticeToast(props: MediaNoticeToastProps) { 14 + return ( 15 + <Presence> 16 + <Show when={props.notice}> 17 + {(current) => ( 18 + <Motion.div 19 + role={current().kind === "error" ? "alert" : "status"} 20 + aria-live={current().kind === "error" ? "assertive" : "polite"} 21 + class="fixed bottom-6 left-1/2 z-70 grid w-max max-w-[min(34rem,calc(100vw-2rem))] -translate-x-1/2 grid-cols-[auto_1fr_auto_auto] items-center gap-2 rounded-full bg-surface-container-high px-4 py-3 text-on-surface shadow-[0_24px_40px_rgba(0,0,0,0.4),inset_0_0_0_1px_rgba(255,255,255,0.06)] backdrop-blur-[20px] max-sm:w-[calc(100vw-1.5rem)]" 22 + initial={{ opacity: 0, y: 20, scale: 0.96 }} 23 + animate={{ opacity: 1, y: 0, scale: 1 }} 24 + exit={{ opacity: 0, y: 16, scale: 0.94 }} 25 + transition={{ duration: 0.2 }}> 26 + <Icon 27 + kind={current().kind === "error" ? "danger" : "complete"} 28 + aria-hidden="true" 29 + classList={{ 30 + "text-emerald-300": current().kind === "success", 31 + "text-error": current().kind === "error", 32 + }} /> 33 + <p class="m-0 min-w-0 text-[0.875rem] text-on-surface">{current().message}</p> 34 + <Show when={current().kind === "success"}> 35 + <button 36 + type="button" 37 + class="rounded-full border-0 bg-primary/20 px-3 py-1.5 text-xs font-medium text-primary transition hover:bg-primary/30" 38 + onClick={() => { 39 + const notice = current(); 40 + if (notice.kind === "success") { 41 + void props.onOpenPath?.(notice.path); 42 + } 43 + }}> 44 + Open in Finder 45 + </button> 46 + </Show> 47 + <button 48 + type="button" 49 + class="cursor-pointer rounded-full border-0 bg-transparent p-[0.35rem] text-inherit hover:bg-surface-bright" 50 + onClick={() => props.onDismiss()}> 51 + <Icon kind="close" aria-hidden="true" /> 52 + <span class="sr-only">Dismiss message</span> 53 + </button> 54 + </Motion.div> 55 + )} 56 + </Show> 57 + </Presence> 58 + ); 59 + }
+56 -1
src/components/feeds/PostCard.test.tsx
··· 1 1 import { buildHashtagRoute } from "$/lib/search-routes"; 2 2 import { fireEvent, render, screen, waitFor } from "@solidjs/testing-library"; 3 - import { describe, expect, it, vi } from "vitest"; 3 + import { beforeEach, describe, expect, it, vi } from "vitest"; 4 4 import { PostCard } from "./PostCard"; 5 + 6 + const downloadImageMock = vi.hoisted(() => vi.fn()); 7 + const downloadVideoMock = vi.hoisted(() => vi.fn()); 8 + const listenMock = vi.hoisted(() => vi.fn()); 9 + 10 + vi.mock("$/lib/api/media", () => ({ downloadImage: downloadImageMock, downloadVideo: downloadVideoMock })); 11 + vi.mock("@tauri-apps/api/event", () => ({ listen: listenMock })); 5 12 6 13 function createPost() { 7 14 return { ··· 28 35 } 29 36 30 37 describe("PostCard", () => { 38 + beforeEach(() => { 39 + downloadImageMock.mockReset(); 40 + downloadVideoMock.mockReset(); 41 + listenMock.mockReset(); 42 + listenMock.mockResolvedValue(() => {}); 43 + }); 44 + 31 45 it("renders links, mentions, and hashtags from facets", () => { 32 46 render(() => <PostCard post={createPost()} />); 33 47 ··· 145 159 "href", 146 160 "https://bsky.app/profile/bob.test/post/quoted", 147 161 ); 162 + }); 163 + 164 + it("renders inline video embed player for video attachments", () => { 165 + render(() => ( 166 + <PostCard 167 + post={{ 168 + ...createPost(), 169 + embed: { 170 + $type: "app.bsky.embed.video#view", 171 + alt: "Attached clip", 172 + playlist: "https://cdn.example.com/video/master.m3u8", 173 + thumbnail: "https://cdn.example.com/video/thumb.jpg", 174 + }, 175 + }} /> 176 + )); 177 + 178 + expect(screen.getByRole("button", { name: "Play video" })).toBeInTheDocument(); 179 + expect(screen.getByText("Attached clip")).toBeInTheDocument(); 180 + }); 181 + 182 + it("opens gallery on image click and supports right-click save", async () => { 183 + downloadImageMock.mockResolvedValue({ bytes: 40, path: "/tmp/post-image.jpg" }); 184 + render(() => ( 185 + <PostCard 186 + post={{ 187 + ...createPost(), 188 + embed: { 189 + $type: "app.bsky.embed.images#view", 190 + images: [{ alt: "Inline image", fullsize: "https://cdn.example.com/post-image.jpg" }], 191 + }, 192 + }} /> 193 + )); 194 + 195 + const inlineImage = screen.getByAltText("Inline image"); 196 + fireEvent.click(inlineImage); 197 + expect(await screen.findByText("1 / 1")).toBeInTheDocument(); 198 + 199 + fireEvent.contextMenu(inlineImage); 200 + fireEvent.click(screen.getByRole("menuitem", { name: "Save image" })); 201 + 202 + await waitFor(() => expect(downloadImageMock).toHaveBeenCalledWith("https://cdn.example.com/post-image.jpg")); 148 203 }); 149 204 });
+149 -24
src/components/feeds/PostCard.tsx
··· 1 + import { ImageGallery } from "$/components/feeds/ImageGallery"; 2 + import { type MediaNotice, MediaNoticeToast } from "$/components/feeds/MediaNoticeToast"; 3 + import { VideoEmbed } from "$/components/feeds/VideoEmbed"; 1 4 import { ContextMenu, type ContextMenuAnchor, type ContextMenuItem } from "$/components/shared/ContextMenu"; 2 5 import { Icon } from "$/components/shared/Icon"; 3 6 import { PostRichText } from "$/components/shared/PostRichText"; 4 7 import { QuotedPostPreview } from "$/components/shared/QuotedPostPreview"; 8 + import { downloadImage } from "$/lib/api/media"; 5 9 import { 6 10 buildPublicPostUrl, 7 11 formatRelativeTime, ··· 17 21 } from "$/lib/feeds"; 18 22 import { buildProfileRoute, getProfileRouteActor } from "$/lib/profile"; 19 23 import type { EmbedView, FeedViewPost, ImagesEmbedView, PostView, ProfileViewBasic, RichTextFacet } from "$/lib/types"; 20 - import { formatCount, formatHandle } from "$/lib/utils/text"; 21 - import { createMemo, createSignal, For, Match, type ParentProps, Show, Switch } from "solid-js"; 24 + import { formatCount, formatHandle, normalizeError } from "$/lib/utils/text"; 25 + import { revealItemInDir } from "@tauri-apps/plugin-opener"; 26 + import { createMemo, createSignal, For, Match, onCleanup, type ParentProps, Show, Switch } from "solid-js"; 22 27 import { Motion } from "solid-motionone"; 23 28 24 29 type PostCardProps = { ··· 61 66 62 67 return `${getDisplayName(reason.by)} reposted`; 63 68 }); 69 + 64 70 const replyLabel = createMemo(() => { 65 71 const item = props.item; 66 72 if (!item || !isReplyItem(item)) { ··· 74 80 75 81 return "Reply in thread"; 76 82 }); 83 + 77 84 const [menuAnchor, setMenuAnchor] = createSignal<ContextMenuAnchor | null>(null); 78 85 const [menuOpen, setMenuOpen] = createSignal(false); 79 86 let menuTriggerRef: HTMLButtonElement | undefined; ··· 253 260 254 261 return ( 255 262 <div 256 - class="min-w-0 rounded-2xl outline-none transition duration-150 ease-out p-2" 263 + class="min-w-0 rounded-2xl p-2 outline-none transition duration-150 ease-out" 257 264 classList={{ 258 265 "cursor-pointer hover:bg-white/2 focus-visible:bg-white/3 focus-visible:ring-1 focus-visible:ring-primary/30": 259 266 interactive(), ··· 405 412 <Show when={props.post.embed}> 406 413 {(current) => ( 407 414 <div class="mt-4"> 408 - <EmbedContent embed={current()} /> 415 + <EmbedContent embed={current()} post={props.post} /> 409 416 </div> 410 417 )} 411 418 </Show> 412 419 ); 413 420 } 414 421 415 - function EmbedContent(props: { embed: EmbedView }) { 422 + function EmbedContent(props: { embed: EmbedView; post: PostView }) { 416 423 return ( 417 424 <Switch> 418 425 <Match when={props.embed.$type === "app.bsky.embed.images#view"}> 419 - <ImageEmbed embed={props.embed as ImagesEmbedView} /> 426 + <ImageEmbed embed={props.embed as ImagesEmbedView} post={props.post} /> 420 427 </Match> 421 428 <Match when={props.embed.$type === "app.bsky.embed.external#view"}> 422 429 <ExternalEmbed ··· 426 433 uri={(props.embed as { external: { uri?: string } }).external.uri} /> 427 434 </Match> 428 435 <Match when={props.embed.$type === "app.bsky.embed.video#view"}> 429 - <ExternalEmbed 430 - description={(props.embed as { alt?: string }).alt} 431 - thumb={(props.embed as { thumbnail?: string }).thumbnail} 432 - title="Video attachment" 433 - uri={(props.embed as { playlist?: string }).playlist} /> 436 + <VideoEmbed 437 + alt={(props.embed as { alt?: string }).alt} 438 + aspectRatio={(props.embed as { aspectRatio?: { height: number; width: number } }).aspectRatio} 439 + playlist={(props.embed as { playlist?: string }).playlist} 440 + thumbnail={(props.embed as { thumbnail?: string }).thumbnail} /> 434 441 </Match> 435 442 <Match when={props.embed.$type === "app.bsky.embed.record#view"}> 436 443 <RecordEmbedContent embed={props.embed} /> 437 444 </Match> 438 445 <Match when={props.embed.$type === "app.bsky.embed.recordWithMedia#view"}> 439 - <RecordWithMediaEmbedContent embed={props.embed} /> 446 + <RecordWithMediaEmbedContent embed={props.embed} post={props.post} /> 440 447 </Match> 441 448 </Switch> 442 449 ); ··· 452 459 ); 453 460 } 454 461 455 - function RecordWithMediaEmbedContent(props: { embed: EmbedView }) { 462 + function RecordWithMediaEmbedContent(props: { embed: EmbedView; post: PostView }) { 456 463 const media = () => ("media" in props.embed ? props.embed.media : null); 457 464 458 465 return ( 459 466 <div class="grid gap-3"> 460 - <Show when={media()}>{(current) => <EmbedContent embed={current() as EmbedView} />}</Show> 467 + <Show when={media()}>{(current) => <EmbedContent embed={current() as EmbedView} post={props.post} />}</Show> 461 468 <QuoteEmbed 462 469 author={getQuotedAuthor(props.embed)} 463 470 href={getQuotedHref(props.embed)} ··· 467 474 ); 468 475 } 469 476 470 - function ImageEmbed(props: { embed: ImagesEmbedView }) { 477 + function ImageEmbed(props: { embed: ImagesEmbedView; post: PostView }) { 471 478 const images = createMemo(() => props.embed.images.slice(0, 4)); 479 + const [galleryStartIndex, setGalleryStartIndex] = createSignal<number | null>(null); 480 + const [menuAnchor, setMenuAnchor] = createSignal<ContextMenuAnchor | null>(null); 481 + const [menuOpen, setMenuOpen] = createSignal(false); 482 + const [menuImageUrl, setMenuImageUrl] = createSignal<string | null>(null); 483 + const [downloadPending, setDownloadPending] = createSignal(false); 484 + const [notice, setNotice] = createSignal<MediaNotice | null>(null); 485 + let noticeTimer: ReturnType<typeof setTimeout> | null = null; 486 + 487 + const postText = createMemo(() => getPostText(props.post)); 488 + const authorHandle = createMemo(() => formatHandle(props.post.author.handle, props.post.author.did)); 489 + const profileHref = createMemo(() => buildProfileRoute(getProfileRouteActor(props.post.author))); 490 + const menuItems = createMemo<ContextMenuItem[]>( 491 + () => [{ 492 + disabled: !menuImageUrl() || downloadPending(), 493 + icon: downloadPending() ? "i-ri-loader-4-line animate-spin" : "i-ri-download-2-line", 494 + label: downloadPending() ? "Saving..." : "Save image", 495 + onSelect: () => void downloadFromContextMenu(), 496 + }] 497 + ); 498 + 499 + onCleanup(() => { 500 + if (noticeTimer !== null) { 501 + clearTimeout(noticeTimer); 502 + } 503 + }); 504 + 505 + function dismissNotice() { 506 + setNotice(null); 507 + if (noticeTimer !== null) { 508 + clearTimeout(noticeTimer); 509 + noticeTimer = null; 510 + } 511 + } 512 + 513 + function queueNotice(next: MediaNotice) { 514 + dismissNotice(); 515 + setNotice(next); 516 + noticeTimer = setTimeout(() => { 517 + setNotice(null); 518 + noticeTimer = null; 519 + }, 6000); 520 + } 521 + 522 + function closeMenu() { 523 + setMenuOpen(false); 524 + setMenuAnchor(null); 525 + setMenuImageUrl(null); 526 + } 527 + 528 + function openGallery(index: number, event: MouseEvent) { 529 + event.stopPropagation(); 530 + setGalleryStartIndex(index); 531 + } 532 + 533 + function openImageMenu(event: MouseEvent, url: string | undefined) { 534 + event.preventDefault(); 535 + event.stopPropagation(); 536 + 537 + setMenuImageUrl(url ?? null); 538 + setMenuAnchor({ kind: "point", x: event.clientX, y: event.clientY }); 539 + setMenuOpen(true); 540 + } 541 + 542 + async function downloadFromContextMenu() { 543 + const url = menuImageUrl(); 544 + if (!url || downloadPending()) { 545 + return; 546 + } 547 + 548 + setDownloadPending(true); 549 + try { 550 + const result = await downloadImage(url); 551 + queueNotice({ kind: "success", message: `Saved ${filenameFromPath(result.path)}.`, path: result.path }); 552 + } catch (error) { 553 + queueNotice({ kind: "error", message: toDownloadErrorMessage(error) }); 554 + } finally { 555 + setDownloadPending(false); 556 + } 557 + } 558 + 472 559 return ( 473 - <div class="grid min-w-0 gap-2" classList={{ "grid-cols-2": props.embed.images.length > 1 }}> 474 - <For each={images()}> 475 - {(image) => ( 476 - <div class="overflow-hidden rounded-[1.2rem] bg-black/30 shadow-[inset_0_0_0_1px_rgba(255,255,255,0.05)]"> 477 - <img class="max-h-88 w-full object-cover" src={image.fullsize ?? image.thumb} alt={image.alt ?? ""} /> 478 - </div> 479 - )} 480 - </For> 481 - </div> 560 + <> 561 + <div class="grid min-w-0 gap-2" classList={{ "grid-cols-2": props.embed.images.length > 1 }}> 562 + <For each={images()}> 563 + {(image, index) => ( 564 + <button 565 + type="button" 566 + class="overflow-hidden rounded-[1.2rem] border-0 bg-black/30 p-0 shadow-[inset_0_0_0_1px_rgba(255,255,255,0.05)]" 567 + onClick={(event) => openGallery(index(), event)} 568 + onContextMenu={(event) => openImageMenu(event, image.fullsize ?? image.thumb)}> 569 + <img class="max-h-88 w-full object-cover" src={image.fullsize ?? image.thumb} alt={image.alt ?? ""} /> 570 + </button> 571 + )} 572 + </For> 573 + </div> 574 + 575 + <ImageGallery 576 + authorHandle={authorHandle()} 577 + authorHref={profileHref()} 578 + images={images()} 579 + open={galleryStartIndex() !== null} 580 + postText={postText()} 581 + startIndex={galleryStartIndex() ?? 0} 582 + onClose={() => setGalleryStartIndex(null)} /> 583 + 584 + <ContextMenu 585 + anchor={menuAnchor()} 586 + items={menuItems()} 587 + label="Image actions" 588 + open={menuOpen()} 589 + onClose={closeMenu} /> 590 + 591 + <MediaNoticeToast notice={notice()} onDismiss={dismissNotice} onOpenPath={revealItemInDir} /> 592 + </> 482 593 ); 483 594 } 484 595 ··· 519 630 function isInteractiveTarget(target: EventTarget | null) { 520 631 return target instanceof Element && !!target.closest("a, button, input, textarea, select, [role='menuitem']"); 521 632 } 633 + 634 + function filenameFromPath(path: string) { 635 + const parts = path.split(/[/\\]/u); 636 + return parts.at(-1) || "downloaded file"; 637 + } 638 + 639 + function toDownloadErrorMessage(error: unknown) { 640 + const message = normalizeError(error); 641 + if (/download folder|writable|save|directory|exists/iu.test(message)) { 642 + return "Couldn't save — check that the download folder exists."; 643 + } 644 + 645 + return "Couldn't save this image right now."; 646 + }
+65
src/components/feeds/VideoEmbed.test.tsx
··· 1 + import { fireEvent, render, screen, waitFor } from "@solidjs/testing-library"; 2 + import { beforeEach, describe, expect, it, vi } from "vitest"; 3 + import { VideoEmbed } from "./VideoEmbed"; 4 + 5 + const downloadVideoMock = vi.hoisted(() => vi.fn()); 6 + const listenMock = vi.hoisted(() => vi.fn()); 7 + const revealItemInDirMock = vi.hoisted(() => vi.fn()); 8 + 9 + vi.mock("$/lib/api/media", () => ({ downloadVideo: downloadVideoMock })); 10 + vi.mock("@tauri-apps/api/event", () => ({ listen: listenMock })); 11 + vi.mock("@tauri-apps/plugin-opener", () => ({ revealItemInDir: revealItemInDirMock })); 12 + 13 + describe("VideoEmbed", () => { 14 + beforeEach(() => { 15 + vi.restoreAllMocks(); 16 + downloadVideoMock.mockReset(); 17 + listenMock.mockReset(); 18 + revealItemInDirMock.mockReset(); 19 + listenMock.mockResolvedValue(() => {}); 20 + revealItemInDirMock.mockResolvedValue(void 0); 21 + }); 22 + 23 + it("starts playback from the click-to-play overlay and renders caption text", async () => { 24 + const playSpy = vi.spyOn(HTMLMediaElement.prototype, "play").mockResolvedValue(); 25 + vi.spyOn(HTMLMediaElement.prototype, "canPlayType").mockReturnValue("probably"); 26 + 27 + render(() => ( 28 + <VideoEmbed 29 + alt="Clip caption" 30 + playlist="https://cdn.example.com/video/master.m3u8" 31 + thumbnail="https://cdn.example.com/video/thumb.jpg" /> 32 + )); 33 + 34 + fireEvent.click(screen.getByRole("button", { name: "Play video" })); 35 + 36 + await waitFor(() => expect(playSpy).toHaveBeenCalled()); 37 + const video = document.querySelector("video"); 38 + expect(video?.src).toContain("master.m3u8"); 39 + expect(screen.getByText("Clip caption")).toBeInTheDocument(); 40 + }); 41 + 42 + it("downloads a video and offers an open-in-finder action", async () => { 43 + downloadVideoMock.mockResolvedValue({ bytes: 42, path: "/tmp/example.mp4" }); 44 + 45 + render(() => <VideoEmbed playlist="https://cdn.example.com/video/master.m3u8" />); 46 + 47 + fireEvent.click(screen.getByRole("button", { name: "Download video" })); 48 + 49 + await waitFor(() => expect(downloadVideoMock).toHaveBeenCalledWith("https://cdn.example.com/video/master.m3u8")); 50 + expect(await screen.findByText("Saved example.mp4.")).toBeInTheDocument(); 51 + 52 + fireEvent.click(screen.getByRole("button", { name: "Open in Finder" })); 53 + await waitFor(() => expect(revealItemInDirMock).toHaveBeenCalledWith("/tmp/example.mp4")); 54 + }); 55 + 56 + it("shows a human-readable error when a download fails", async () => { 57 + downloadVideoMock.mockRejectedValue(new Error("download folder missing")); 58 + 59 + render(() => <VideoEmbed playlist="https://cdn.example.com/video/master.m3u8" />); 60 + 61 + fireEvent.click(screen.getByRole("button", { name: "Download video" })); 62 + 63 + expect(await screen.findByText("Couldn't save — check that the download folder exists.")).toBeInTheDocument(); 64 + }); 65 + });
+285
src/components/feeds/VideoEmbed.tsx
··· 1 + import { type MediaNotice, MediaNoticeToast } from "$/components/feeds/MediaNoticeToast"; 2 + import { Icon } from "$/components/shared/Icon"; 3 + import { type DownloadProgress, downloadVideo } from "$/lib/api/media"; 4 + import { normalizeError } from "$/lib/utils/text"; 5 + import { listen } from "@tauri-apps/api/event"; 6 + import { revealItemInDir } from "@tauri-apps/plugin-opener"; 7 + import { createMemo, createSignal, onCleanup, Show } from "solid-js"; 8 + import type { JSX } from "solid-js"; 9 + 10 + type VideoEmbedProps = { 11 + alt?: string; 12 + aspectRatio?: { height: number; width: number }; 13 + playlist?: string; 14 + thumbnail?: string; 15 + }; 16 + 17 + type HlsLike = { 18 + attachMedia: (video: HTMLVideoElement) => void; 19 + destroy: () => void; 20 + loadSource: (url: string) => void; 21 + on: (event: string, callback: () => void) => void; 22 + }; 23 + 24 + export function VideoEmbed(props: VideoEmbedProps) { 25 + const [started, setStarted] = createSignal(false); 26 + const [downloadPending, setDownloadPending] = createSignal(false); 27 + const [downloadProgress, setDownloadProgress] = createSignal<DownloadProgress | null>(null); 28 + const [notice, setNotice] = createSignal<MediaNotice | null>(null); 29 + const [hlsLoading, setHlsLoading] = createSignal(false); 30 + let noticeTimer: ReturnType<typeof setTimeout> | null = null; 31 + let hls: HlsLike | null = null; 32 + let videoRef: HTMLVideoElement | undefined; 33 + 34 + const aspectRatio = createMemo(() => { 35 + const ratio = props.aspectRatio; 36 + if (!ratio || ratio.width <= 0 || ratio.height <= 0) { 37 + return "16 / 9"; 38 + } 39 + 40 + return `${ratio.width} / ${ratio.height}`; 41 + }); 42 + const hasPlaylist = createMemo(() => !!props.playlist?.trim()); 43 + const progressLabel = createMemo(() => { 44 + const progress = downloadProgress(); 45 + if (!progress || progress.totalSegments <= 0) { 46 + return null; 47 + } 48 + 49 + return `${progress.downloadedSegments}/${progress.totalSegments}`; 50 + }); 51 + 52 + function dismissNotice() { 53 + setNotice(null); 54 + if (noticeTimer !== null) { 55 + clearTimeout(noticeTimer); 56 + noticeTimer = null; 57 + } 58 + } 59 + 60 + function queueNotice(next: MediaNotice) { 61 + dismissNotice(); 62 + setNotice(next); 63 + noticeTimer = setTimeout(() => { 64 + setNotice(null); 65 + noticeTimer = null; 66 + }, 6000); 67 + } 68 + 69 + function destroyHlsInstance() { 70 + hls?.destroy(); 71 + hls = null; 72 + } 73 + 74 + onCleanup(() => { 75 + destroyHlsInstance(); 76 + if (noticeTimer !== null) { 77 + clearTimeout(noticeTimer); 78 + } 79 + }); 80 + 81 + async function play() { 82 + const playlist = props.playlist?.trim(); 83 + if (!playlist || !videoRef) { 84 + return; 85 + } 86 + 87 + setStarted(true); 88 + 89 + try { 90 + await attachSource(playlist); 91 + await videoRef.play(); 92 + } catch (error) { 93 + queueNotice({ kind: "error", message: toPlaybackMessage(error) }); 94 + setStarted(false); 95 + } finally { 96 + setHlsLoading(false); 97 + } 98 + } 99 + 100 + async function attachSource(url: string) { 101 + if (!videoRef) { 102 + return; 103 + } 104 + 105 + destroyHlsInstance(); 106 + if (!isM3u8Url(url)) { 107 + videoRef.src = url; 108 + return; 109 + } 110 + 111 + if (videoRef.canPlayType("application/vnd.apple.mpegurl")) { 112 + videoRef.src = url; 113 + return; 114 + } 115 + 116 + setHlsLoading(true); 117 + const { default: Hls } = await import("hls.js"); 118 + if (!Hls.isSupported()) { 119 + videoRef.src = url; 120 + return; 121 + } 122 + 123 + const instance = new Hls(); 124 + instance.on(Hls.Events.MEDIA_ATTACHED, () => { 125 + instance.loadSource(url); 126 + }); 127 + instance.attachMedia(videoRef); 128 + hls = instance as unknown as HlsLike; 129 + } 130 + 131 + async function handleDownload(event: MouseEvent) { 132 + event.stopPropagation(); 133 + const playlist = props.playlist?.trim(); 134 + if (!playlist || downloadPending()) { 135 + return; 136 + } 137 + 138 + setDownloadPending(true); 139 + setDownloadProgress(null); 140 + let unlistenProgress: (() => void) | undefined; 141 + try { 142 + unlistenProgress = await listen<DownloadProgress>("download-progress", ({ payload }) => { 143 + if (payload.url === playlist) { 144 + setDownloadProgress(payload); 145 + } 146 + }); 147 + } catch { 148 + unlistenProgress = undefined; 149 + } 150 + 151 + try { 152 + const result = await downloadVideo(playlist); 153 + queueNotice({ kind: "success", message: `Saved ${filenameFromPath(result.path)}.`, path: result.path }); 154 + } catch (error) { 155 + queueNotice({ kind: "error", message: toDownloadErrorMessage(error, "Couldn't save the video right now.") }); 156 + } finally { 157 + unlistenProgress?.(); 158 + setDownloadPending(false); 159 + setTimeout(() => setDownloadProgress(null), 500); 160 + } 161 + } 162 + 163 + return ( 164 + <> 165 + <div class="grid min-w-0 gap-2" onClick={(event) => event.stopPropagation()}> 166 + <VideoPlayerStage 167 + aspectRatio={aspectRatio()} 168 + hasPlaylist={hasPlaylist() && !started()} 169 + hlsLoading={hlsLoading()} 170 + poster={props.thumbnail} 171 + started={started()} 172 + onPlay={() => void play()} 173 + onVideoRef={(element) => { 174 + videoRef = element; 175 + }} /> 176 + 177 + <div class="flex min-h-8 flex-wrap items-center justify-between gap-2"> 178 + <Show when={props.alt}> 179 + {(alt) => <p class="m-0 text-sm leading-normal text-on-surface-variant">{alt()}</p>} 180 + </Show> 181 + <button 182 + type="button" 183 + disabled={!hasPlaylist() || downloadPending()} 184 + class="inline-flex items-center gap-1.5 rounded-full border-0 bg-surface-container-high px-3 py-1.5 text-xs text-on-surface transition duration-150 ease-out hover:bg-surface-bright disabled:cursor-wait disabled:opacity-65" 185 + aria-label="Download video" 186 + onClick={(event) => void handleDownload(event)}> 187 + <Icon 188 + aria-hidden="true" 189 + iconClass={downloadPending() ? "i-ri-loader-4-line animate-spin" : "i-ri-download-2-line"} /> 190 + <span> 191 + {downloadPending() ? (progressLabel() ? `Saving ${progressLabel()}` : "Saving...") : "Download"} 192 + </span> 193 + </button> 194 + </div> 195 + </div> 196 + 197 + <MediaNoticeToast notice={notice()} onDismiss={dismissNotice} onOpenPath={revealItemInDir} /> 198 + </> 199 + ); 200 + } 201 + 202 + function containerStyle(ratio: string): JSX.CSSProperties { 203 + return { "aspect-ratio": ratio }; 204 + } 205 + 206 + function filenameFromPath(path: string) { 207 + const parts = path.split(/[/\\]/u); 208 + return parts.at(-1) || "downloaded file"; 209 + } 210 + 211 + function isM3u8Url(value: string) { 212 + return /\.m3u8($|[?#])/iu.test(value); 213 + } 214 + 215 + function toDownloadErrorMessage(error: unknown, fallback: string) { 216 + const message = normalizeError(error); 217 + if (/download folder|writable|save|directory|exists/iu.test(message)) { 218 + return "Couldn't save — check that the download folder exists."; 219 + } 220 + 221 + return fallback; 222 + } 223 + 224 + function toPlaybackMessage(error: unknown) { 225 + const message = normalizeError(error); 226 + if (!message || message === "AbortError") { 227 + return "Couldn't start playback."; 228 + } 229 + 230 + return "Couldn't start playback right now."; 231 + } 232 + 233 + function VideoPlayerStage( 234 + props: { 235 + aspectRatio: string; 236 + hasPlaylist: boolean; 237 + hlsLoading: boolean; 238 + poster?: string; 239 + started: boolean; 240 + onPlay: () => void; 241 + onVideoRef: (element: HTMLVideoElement) => void; 242 + }, 243 + ) { 244 + return ( 245 + <div 246 + class="relative w-full overflow-hidden rounded-[1.2rem] bg-black/40 shadow-[inset_0_0_0_1px_rgba(255,255,255,0.05)]" 247 + style={containerStyle(props.aspectRatio)}> 248 + <video 249 + ref={(element) => props.onVideoRef(element)} 250 + class="h-full w-full object-cover" 251 + controls={props.started} 252 + playsinline 253 + poster={props.poster} /> 254 + <Show when={props.hasPlaylist}> 255 + <PlayOverlay onPlay={props.onPlay} /> 256 + </Show> 257 + <Show when={props.hlsLoading}> 258 + <LoadingBadge /> 259 + </Show> 260 + </div> 261 + ); 262 + } 263 + 264 + function PlayOverlay(props: { onPlay: () => void }) { 265 + return ( 266 + <button 267 + type="button" 268 + aria-label="Play video" 269 + class="absolute inset-0 grid place-items-center border-0 bg-black/35 backdrop-blur-[2px] transition hover:bg-black/45" 270 + onClick={() => props.onPlay()}> 271 + <span class="grid h-16 w-16 place-items-center rounded-full bg-primary/88 text-on-primary-fixed shadow-[0_16px_30px_rgba(0,0,0,0.32)]"> 272 + <Icon aria-hidden="true" iconClass="i-ri-play-fill text-3xl" /> 273 + </span> 274 + </button> 275 + ); 276 + } 277 + 278 + function LoadingBadge() { 279 + return ( 280 + <div class="absolute left-3 top-3 inline-flex items-center gap-1.5 rounded-full bg-black/50 px-3 py-1.5 text-xs text-on-surface"> 281 + <Icon aria-hidden="true" iconClass="i-ri-loader-4-line animate-spin" /> 282 + <span>Loading stream</span> 283 + </div> 284 + ); 285 + }
+1 -4
src/components/shared/ContextMenu.tsx
··· 1 1 import { Icon } from "$/components/shared/Icon"; 2 + import { clamp } from "$/lib/utils/text"; 2 3 import { createEffect, createMemo, createSignal, For, onCleanup, Show } from "solid-js"; 3 4 import { Portal } from "solid-js/web"; 4 5 ··· 202 203 </Show> 203 204 </Portal> 204 205 ); 205 - } 206 - 207 - function clamp(value: number, min: number, max: number) { 208 - return Math.min(Math.max(value, min), max); 209 206 } 210 207 211 208 function findLastEnabledIndex(items: ContextMenuItem[]) {
+28
src/lib/api/media.ts
··· 1 + import { invoke } from "@tauri-apps/api/core"; 2 + 3 + export type DownloadResult = { path: string; bytes: number }; 4 + 5 + export type DownloadProgress = { 6 + url: string; 7 + path: string; 8 + downloadedBytes: number; 9 + downloadedSegments: number; 10 + totalSegments: number; 11 + complete: boolean; 12 + }; 13 + 14 + export function getDownloadDirectory() { 15 + return invoke<string>("get_download_directory"); 16 + } 17 + 18 + export function setDownloadDirectory(path: string) { 19 + return invoke("set_download_directory", { path }); 20 + } 21 + 22 + export function downloadImage(url: string, filename?: string | null) { 23 + return invoke<DownloadResult>("download_image", { filename: filename ?? null, url }); 24 + } 25 + 26 + export function downloadVideo(url: string, filename?: string | null) { 27 + return invoke<DownloadResult>("download_video", { filename: filename ?? null, url }); 28 + }
+4
src/lib/utils/text.ts
··· 98 98 99 99 return handle.startsWith("did:") || handle.startsWith("@") ? handle : `@${handle}`; 100 100 } 101 + 102 + export function clamp(value: number, min: number, max: number) { 103 + return Math.min(Math.max(value, min), max); 104 + }