Loading media...
- {:else if mediaEvents.length === 0}
+ {:else if mediaItems.length === 0}
No media posts yet.
{:else}
-
- {#each mediaEvents as media (media.id)}
-
+
+ {#if mediaViewerUrl && mediaViewerOpen}
+
+ {/if}
{/if}
{:else if activeTab === 'notifications'}
{#if notifications.length === 0}
@@ -1349,4 +1650,235 @@
max-width: 100%;
}
}
+
+ /* Media gallery - Masonry layout like Pinterest */
+ .media-gallery {
+ column-width: 150px;
+ column-gap: 0.75rem;
+ margin-top: 1rem;
+ }
+
+ @media (min-width: 640px) {
+ .media-gallery {
+ column-gap: 1rem;
+ }
+ }
+
+ .media-gallery-item {
+ position: relative;
+ display: inline-block;
+ width: 150px;
+ margin-bottom: 0.75rem;
+ cursor: pointer;
+ border-radius: 0.5rem;
+ overflow: hidden;
+ background: var(--fog-highlight, #f3f4f6);
+ border: 1px solid var(--fog-border, #e5e7eb);
+ transition: transform 0.2s, box-shadow 0.2s;
+ outline: none;
+ break-inside: avoid;
+ page-break-inside: avoid;
+ }
+
+ @media (min-width: 640px) {
+ .media-gallery-item {
+ margin-bottom: 1rem;
+ }
+ }
+
+ .media-gallery-item:focus {
+ outline: 2px solid var(--fog-accent, #3b82f6);
+ outline-offset: 2px;
+ }
+
+ :global(.dark) .media-gallery-item {
+ background: var(--fog-dark-highlight, #374151);
+ border-color: var(--fog-dark-border, #475569);
+ }
+
+ .media-gallery-item:hover {
+ transform: translateY(-2px);
+ box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15);
+ }
+
+ :global(.dark) .media-gallery-item:hover {
+ box-shadow: 0 4px 8px rgba(0, 0, 0, 0.3);
+ }
+
+ .media-thumbnail {
+ width: 100%;
+ height: auto;
+ object-fit: cover;
+ display: block;
+ max-width: 100%;
+ }
+
+ .video-thumbnail,
+ .audio-thumbnail {
+ width: 100%;
+ min-height: 200px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ background: linear-gradient(135deg, var(--fog-accent, #3b82f6) 0%, var(--fog-text-light, #52667a) 100%);
+ position: relative;
+ aspect-ratio: 4/3;
+ }
+
+ :global(.dark) .video-thumbnail,
+ :global(.dark) .audio-thumbnail {
+ background: linear-gradient(135deg, var(--fog-accent, #3b82f6) 0%, var(--fog-text-light, #52667a) 100%);
+ opacity: 0.8;
+ }
+
+ .video-placeholder-bg {
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ background: linear-gradient(135deg, var(--fog-accent, #3b82f6) 0%, var(--fog-text-light, #52667a) 100%);
+ z-index: -1;
+ }
+
+ .video-thumb-img {
+ width: 100%;
+ height: auto;
+ object-fit: cover;
+ display: block;
+ }
+
+ .video-thumb-video {
+ width: 100%;
+ height: auto;
+ object-fit: cover;
+ pointer-events: none;
+ display: block;
+ position: relative;
+ z-index: 1;
+ }
+
+ .video-play-overlay {
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ transform: translate(-50%, -50%);
+ font-size: 2rem;
+ color: white;
+ text-shadow: 0 2px 4px rgba(0, 0, 0, 0.5);
+ pointer-events: none;
+ }
+
+ .audio-placeholder {
+ color: rgba(255, 255, 255, 0.9);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 100%;
+ height: 100%;
+ }
+
+ .audio-placeholder svg {
+ width: 48px;
+ height: 48px;
+ }
+
+ .media-overlay {
+ position: absolute;
+ top: 0;
+ right: 0;
+ padding: 0.5rem;
+ opacity: 0;
+ transition: opacity 0.2s;
+ outline: none;
+ }
+
+ .media-overlay:focus {
+ opacity: 1;
+ }
+
+ .media-gallery-item:hover .media-overlay {
+ opacity: 1;
+ }
+
+ .media-view-event-btn {
+ background: rgba(0, 0, 0, 0.7);
+ border: none;
+ border-radius: 0.375rem;
+ padding: 0.5rem;
+ cursor: pointer;
+ color: white;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ transition: background 0.2s;
+ }
+
+ .media-view-event-btn:hover {
+ background: rgba(0, 0, 0, 0.9);
+ }
+
+ .media-info {
+ position: absolute;
+ bottom: 0;
+ left: 0;
+ right: 0;
+ background: linear-gradient(to top, rgba(0, 0, 0, 0.85), rgba(0, 0, 0, 0.5), transparent);
+ padding: 0.75rem;
+ color: white;
+ font-size: 0.875rem;
+ opacity: 0;
+ transition: opacity 0.2s;
+ pointer-events: none;
+ max-height: 50%;
+ overflow: hidden;
+ }
+
+ .media-gallery-item:hover .media-info {
+ opacity: 1;
+ }
+
+ .media-title {
+ font-weight: 600;
+ margin-bottom: 0.25rem;
+ line-height: 1.2;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ display: -webkit-box;
+ -webkit-line-clamp: 1;
+ -webkit-box-orient: vertical;
+ }
+
+ .media-summary {
+ font-size: 0.75rem;
+ opacity: 0.9;
+ line-height: 1.3;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ display: -webkit-box;
+ -webkit-line-clamp: 2;
+ -webkit-box-orient: vertical;
+ }
+
+ .media-alt {
+ font-size: 0.75rem;
+ opacity: 0.9;
+ line-height: 1.3;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ display: -webkit-box;
+ -webkit-line-clamp: 2;
+ -webkit-box-orient: vertical;
+ }
+
+ .media-content {
+ font-size: 0.75rem;
+ opacity: 0.9;
+ line-height: 1.3;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ display: -webkit-box;
+ -webkit-line-clamp: 2;
+ -webkit-box-orient: vertical;
+ }
\ No newline at end of file
diff --git a/src/lib/services/cache/indexeddb-store.ts b/src/lib/services/cache/indexeddb-store.ts
index 0632dec..f518275 100644
--- a/src/lib/services/cache/indexeddb-store.ts
+++ b/src/lib/services/cache/indexeddb-store.ts
@@ -5,7 +5,7 @@
import { openDB, type IDBPDatabase } from 'idb';
const DB_NAME = 'aitherboard';
-const DB_VERSION = 12; // Version 7: Added RSS cache store. Version 8: Added markdown cache store. Version 9: Added event archive store. Version 10: Added GIF cache store. Version 11: Added event version history store. Version 12: Migrate preferences from localStorage to IndexedDB
+const DB_VERSION = 13; // Version 7: Added RSS cache store. Version 8: Added markdown cache store. Version 9: Added event archive store. Version 10: Added GIF cache store. Version 11: Added event version history store. Version 12: Migrate preferences from localStorage to IndexedDB. Version 13: Added media cache store
export interface DatabaseSchema {
events: {
@@ -58,6 +58,11 @@ export interface DatabaseSchema {
value: unknown;
indexes: { eventKey: string; pubkey: string; kind: number; savedAt: number };
};
+ media: {
+ key: string; // normalized URL
+ value: unknown;
+ indexes: { cached_at: number };
+ };
}
let dbInstance: IDBPDatabase
| null = null;
@@ -150,6 +155,12 @@ export async function getDB(): Promise> {
eventVersionsStore.createIndex('kind', 'kind', { unique: false });
eventVersionsStore.createIndex('savedAt', 'savedAt', { unique: false });
}
+
+ // Media cache store (images, videos, audio)
+ if (!db.objectStoreNames.contains('media')) {
+ const mediaStore = db.createObjectStore('media', { keyPath: 'url' });
+ mediaStore.createIndex('cached_at', 'cached_at', { unique: false });
+ }
},
blocked() {
// IndexedDB blocked (another tab may have it open)
@@ -202,7 +213,7 @@ export async function getDB(): Promise> {
throw new Error('Failed to open database');
}
const db = dbInstance; // TypeScript narrowing helper
- const criticalStores = ['events', 'profiles', 'preferences', 'eventVersions'];
+ const criticalStores = ['events', 'profiles', 'preferences', 'eventVersions', 'media'];
const missingStores = criticalStores.filter(store => !db.objectStoreNames.contains(store));
if (missingStores.length > 0) {
@@ -262,6 +273,8 @@ export async function getDB(): Promise> {
eventVersionsStore.createIndex('pubkey', 'pubkey', { unique: false });
eventVersionsStore.createIndex('kind', 'kind', { unique: false });
eventVersionsStore.createIndex('savedAt', 'savedAt', { unique: false });
+ const mediaStore = db.createObjectStore('media', { keyPath: 'url' });
+ mediaStore.createIndex('cached_at', 'cached_at', { unique: false });
},
blocked() {
// IndexedDB blocked (another tab may have it open)
diff --git a/src/lib/services/cache/media-cache.ts b/src/lib/services/cache/media-cache.ts
new file mode 100644
index 0000000..358da3e
--- /dev/null
+++ b/src/lib/services/cache/media-cache.ts
@@ -0,0 +1,167 @@
+/**
+ * Media cache using IndexedDB
+ * Stores images, videos, and audio files (including TTS audio) for 30 minutes
+ */
+
+import { getDB } from './indexeddb-store.js';
+
+export interface CachedMedia {
+ url: string; // normalized URL (key)
+ blob: Blob;
+ cached_at: number;
+ type: 'image' | 'video' | 'audio';
+}
+
+const CACHE_MAX_AGE = 30 * 60 * 1000; // 30 minutes
+
+/**
+ * Normalize URL for use as cache key (remove query params and fragments)
+ */
+function normalizeUrl(url: string): string {
+ try {
+ const parsed = new URL(url);
+ return `${parsed.protocol}//${parsed.host}${parsed.pathname}`;
+ } catch {
+ // If URL parsing fails, just remove query params and fragments
+ return url.split('?')[0].split('#')[0];
+ }
+}
+
+/**
+ * Get cached media blob
+ */
+export async function getCachedMedia(url: string): Promise {
+ try {
+ const db = await getDB();
+ const normalizedUrl = normalizeUrl(url);
+ const cached = await db.get('media', normalizedUrl);
+
+ if (!cached) return null;
+
+ const now = Date.now();
+ if ((now - cached.cached_at) >= CACHE_MAX_AGE) {
+ // Cache expired, delete it
+ await db.delete('media', normalizedUrl);
+ return null;
+ }
+
+ return cached.blob;
+ } catch (error) {
+ console.debug('[media-cache] Failed to get cached media:', error);
+ return null;
+ }
+}
+
+/**
+ * Cache media blob
+ */
+export async function cacheMedia(url: string, blob: Blob, type: 'image' | 'video' | 'audio'): Promise {
+ try {
+ const db = await getDB();
+ const normalizedUrl = normalizeUrl(url);
+
+ const cached: CachedMedia = {
+ url: normalizedUrl,
+ blob,
+ cached_at: Date.now(),
+ type
+ };
+
+ await db.put('media', cached);
+ console.debug(`[media-cache] Cached ${type}: ${normalizedUrl}`);
+ } catch (error) {
+ // Cache write failed (non-critical)
+ console.debug('[media-cache] Failed to cache media:', error);
+ }
+}
+
+/**
+ * Load media with caching
+ * Returns a blob URL that can be used in img/video/audio src
+ */
+export async function loadCachedMedia(
+ url: string,
+ type: 'image' | 'video' | 'audio'
+): Promise {
+ // Check cache first
+ const cached = await getCachedMedia(url);
+ if (cached) {
+ return URL.createObjectURL(cached);
+ }
+
+ // Fetch and cache
+ try {
+ const response = await fetch(url);
+ if (!response.ok) {
+ throw new Error(`Failed to fetch media: ${response.status} ${response.statusText}`);
+ }
+
+ const blob = await response.blob();
+
+ // Cache the blob
+ await cacheMedia(url, blob, type);
+
+ // Return blob URL
+ return URL.createObjectURL(blob);
+ } catch (error) {
+ console.error('[media-cache] Failed to load media:', error);
+ // Return original URL as fallback
+ return url;
+ }
+}
+
+/**
+ * Clear stale cached media (older than max age)
+ */
+export async function clearStaleMedia(): Promise {
+ try {
+ const db = await getDB();
+ const tx = db.transaction('media', 'readwrite');
+ const index = tx.store.index('cached_at');
+ const now = Date.now();
+
+ // Get all cached media
+ const allCached = await index.getAll();
+
+ // Delete stale entries
+ const staleKeys = allCached
+ .filter((cached: CachedMedia) => (now - cached.cached_at) >= CACHE_MAX_AGE)
+ .map((cached: CachedMedia) => cached.url);
+
+ if (staleKeys.length > 0) {
+ await Promise.all(staleKeys.map(key => tx.store.delete(key)));
+ console.debug(`[media-cache] Cleared ${staleKeys.length} stale media files`);
+ }
+
+ await tx.done;
+ } catch (error) {
+ console.debug('[media-cache] Failed to clear stale media:', error);
+ }
+}
+
+/**
+ * Get cache stats
+ */
+export async function getMediaCacheStats(): Promise<{ total: number; fresh: number; totalSize: number }> {
+ try {
+ const db = await getDB();
+ const tx = db.transaction('media', 'readonly');
+ const index = tx.store.index('cached_at');
+ const now = Date.now();
+
+ const allCached = await index.getAll();
+ await tx.done;
+
+ const fresh = allCached.filter((cached: CachedMedia) => (now - cached.cached_at) < CACHE_MAX_AGE);
+ const totalSize = fresh.reduce((sum: number, cached: CachedMedia) => sum + cached.blob.size, 0);
+
+ return {
+ total: allCached.length,
+ fresh: fresh.length,
+ totalSize
+ };
+ } catch (error) {
+ console.debug('[media-cache] Failed to get cache stats:', error);
+ return { total: 0, fresh: 0, totalSize: 0 };
+ }
+}
diff --git a/src/lib/services/tts/tts-service.ts b/src/lib/services/tts/tts-service.ts
index 08e103a..cfcecb9 100644
--- a/src/lib/services/tts/tts-service.ts
+++ b/src/lib/services/tts/tts-service.ts
@@ -440,6 +440,17 @@ class PiperProvider extends AudioProvider {
throw new Error('Received empty audio blob from Piper TTS server');
}
+ // Cache the audio blob
+ try {
+ const { cacheMedia } = await import('../../services/cache/media-cache.js');
+ // Create a cache key from text + voice + speed for TTS
+ const cacheKey = `tts:${voice.id}:${speed}:${text.substring(0, 100)}`;
+ await cacheMedia(cacheKey, audioBlob, 'audio');
+ } catch (cacheError) {
+ // Cache failure is non-critical
+ console.debug('Failed to cache TTS audio:', cacheError);
+ }
+
const audioUrl = URL.createObjectURL(audioBlob);
this.setupAudioElement(audioUrl, options?.volume ?? 1.0);
await this.audioElement!.play();