Browse Source

implement media page

master
Silberengel 4 weeks ago
parent
commit
e5f7730163
  1. 67
      src/lib/components/content/MediaViewer.svelte
  2. 47
      src/lib/components/layout/ProfileBadge.svelte
  3. 11
      src/lib/components/write/CreateEventForm.svelte
  4. 624
      src/lib/modules/profiles/ProfilePage.svelte
  5. 17
      src/lib/services/cache/indexeddb-store.ts
  6. 167
      src/lib/services/cache/media-cache.ts
  7. 11
      src/lib/services/tts/tts-service.ts

67
src/lib/components/content/MediaViewer.svelte

@ -1,4 +1,6 @@
<script lang="ts"> <script lang="ts">
import { loadCachedMedia } from '../../services/cache/media-cache.js';
interface Props { interface Props {
url: string; url: string;
isOpen: boolean; isOpen: boolean;
@ -7,6 +9,9 @@
let { url, isOpen, onClose }: Props = $props(); let { url, isOpen, onClose }: Props = $props();
let cachedUrl = $state<string | null>(null);
let blobUrl: string | null = null;
function getMediaType(url: string): 'image' | 'video' | 'audio' | 'unknown' { function getMediaType(url: string): 'image' | 'video' | 'audio' | 'unknown' {
const lower = url.toLowerCase(); const lower = url.toLowerCase();
if (/\.(jpg|jpeg|png|gif|webp|svg|bmp)$/i.test(lower)) return 'image'; if (/\.(jpg|jpeg|png|gif|webp|svg|bmp)$/i.test(lower)) return 'image';
@ -28,6 +33,41 @@
} }
const mediaType = $derived(getMediaType(url)); const mediaType = $derived(getMediaType(url));
// Load cached media when URL changes
$effect(() => {
if (isOpen && url) {
// Clean up previous blob URL
if (blobUrl && blobUrl.startsWith('blob:')) {
URL.revokeObjectURL(blobUrl);
blobUrl = null;
}
const type = getMediaType(url);
if (type !== 'unknown') {
loadCachedMedia(url, type)
.then((loadedUrl) => {
cachedUrl = loadedUrl;
blobUrl = loadedUrl;
})
.catch((error) => {
console.error('Failed to load cached media:', error);
cachedUrl = url; // Fallback to original URL
});
} else {
cachedUrl = url;
}
}
// Cleanup function
return () => {
if (blobUrl && blobUrl.startsWith('blob:')) {
URL.revokeObjectURL(blobUrl);
blobUrl = null;
}
};
});
</script> </script>
{#if isOpen} {#if isOpen}
@ -43,13 +83,13 @@
<button class="media-viewer-close" onclick={onClose} aria-label="Close">×</button> <button class="media-viewer-close" onclick={onClose} aria-label="Close">×</button>
{#if mediaType === 'image'} {#if mediaType === 'image'}
<img src={url} alt="Media" class="media-viewer-media" /> <img src={cachedUrl || url} alt="Media" class="media-viewer-media" />
{:else if mediaType === 'video'} {:else if mediaType === 'video'}
<video src={url} controls class="media-viewer-media" autoplay={false}> <video src={cachedUrl || url} controls class="media-viewer-media" autoplay={false}>
<track kind="captions" /> <track kind="captions" />
</video> </video>
{:else if mediaType === 'audio'} {:else if mediaType === 'audio'}
<audio src={url} controls class="media-viewer-audio" autoplay={false}></audio> <audio src={cachedUrl || url} controls class="media-viewer-audio" autoplay={false}></audio>
{:else} {:else}
<div class="media-viewer-unknown"> <div class="media-viewer-unknown">
<p>Unsupported media type</p> <p>Unsupported media type</p>
@ -91,6 +131,15 @@
align-items: center; align-items: center;
justify-content: center; justify-content: center;
width: 100%; width: 100%;
height: 100%;
padding: 2rem;
box-sizing: border-box;
}
@media (max-width: 640px) {
.media-viewer-content {
padding: 1rem;
}
} }
.media-viewer-close { .media-viewer-close {
@ -128,12 +177,20 @@
} }
.media-viewer-media { .media-viewer-media {
max-width: 100vw; max-width: calc(100vw - 4rem);
max-height: 100vh; max-height: calc(100vh - 4rem);
width: auto; width: auto;
height: auto; height: auto;
object-fit: contain; object-fit: contain;
border-radius: 0.5rem; border-radius: 0.5rem;
display: block;
}
@media (max-width: 640px) {
.media-viewer-media {
max-width: calc(100vw - 2rem);
max-height: calc(100vh - 2rem);
}
} }
@media (max-width: 640px) { @media (max-width: 640px) {

47
src/lib/components/layout/ProfileBadge.svelte

@ -67,6 +67,17 @@
profile = p; profile = p;
} }
lastLoadedPubkey = currentPubkey; lastLoadedPubkey = currentPubkey;
// CRITICAL: Store the kind 0 event in sessionStorage IMMEDIATELY when profile loads
// This ensures the profile page can load instantly when clicked
try {
const cached = await getProfile(currentPubkey);
if (cached && cached.event) {
sessionStorage.setItem('aitherboard_preloadedProfileEvent', JSON.stringify(cached.event));
}
} catch (cacheError) {
// Non-critical - continue even if cache read fails
}
} }
} finally { } finally {
// Only clear loading if this is still the current pubkey // Only clear loading if this is still the current pubkey
@ -150,19 +161,45 @@
async function handleProfileClick(e: MouseEvent) { async function handleProfileClick(e: MouseEvent) {
e.preventDefault(); e.preventDefault();
// Try to get the kind 0 event from cache // The event should already be in sessionStorage from loadProfile()
// But double-check and ensure it's there before navigating
try { try {
// Try cache first (instant) - should be there since we loaded the profile
const cached = await getProfile(pubkey); const cached = await getProfile(pubkey);
if (cached && cached.event) { if (cached && cached.event) {
// Store the kind 0 event in sessionStorage so the profile page can use it // Store it in sessionStorage (might already be there, but ensure it is)
sessionStorage.setItem('aitherboard_preloadedProfileEvent', JSON.stringify(cached.event)); sessionStorage.setItem('aitherboard_preloadedProfileEvent', JSON.stringify(cached.event));
} else {
// Not in cache - try to get from sessionStorage (might have been set by loadProfile)
const existing = sessionStorage.getItem('aitherboard_preloadedProfileEvent');
if (!existing) {
// Last resort: fetch it quickly
try {
const { nostrClient } = await import('../../services/nostr/nostr-client.js');
const { relayManager } = await import('../../services/nostr/relay-manager.js');
const { KIND } = await import('../../types/kind-lookup.js');
const { config } = await import('../../services/nostr/config.js');
const relays = relayManager.getProfileReadRelays();
const events = await nostrClient.fetchEvents(
[{ kinds: [KIND.METADATA], authors: [pubkey], limit: 1 }],
relays,
{ useCache: 'relay-first', cacheResults: true, timeout: config.shortTimeout }
);
if (events.length > 0) {
sessionStorage.setItem('aitherboard_preloadedProfileEvent', JSON.stringify(events[0]));
}
} catch (fetchError) {
// Fetch failed - continue anyway, profile page will handle it
}
}
} }
} catch (error) { } catch (error) {
// Cache read failed - continue without preloaded event // Non-critical - continue anyway
console.warn('Failed to get profile event from cache:', error);
} }
// Navigate to profile page // Navigate IMMEDIATELY - don't wait for anything
goto(`/profile/${pubkey}`); goto(`/profile/${pubkey}`);
} }
</script> </script>

11
src/lib/components/write/CreateEventForm.svelte

@ -823,8 +823,8 @@
{/if} {/if}
{#if imageTag} {#if imageTag}
<div class="metadata-item"> <div class="metadata-item">
<strong class="metadata-label">Image:</strong> <strong class="metadata-label">Image URL:</strong>
<img src={imageTag[1]} alt="" class="preview-image" onerror={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }} /> <span class="metadata-value">{imageTag[1]}</span>
</div> </div>
{/if} {/if}
</div> </div>
@ -1728,13 +1728,6 @@
color: var(--fog-dark-text, #f9fafb); color: var(--fog-dark-text, #f9fafb);
} }
.preview-image {
max-width: 100%;
max-height: 300px;
border-radius: 0.375rem;
margin-top: 0.5rem;
display: block;
}
.d-tag-preview { .d-tag-preview {
padding: 0.75rem; padding: 0.75rem;

624
src/lib/modules/profiles/ProfilePage.svelte

@ -18,7 +18,25 @@
import { KIND } from '../../types/kind-lookup.js'; import { KIND } from '../../types/kind-lookup.js';
import CommentForm from '../comments/CommentForm.svelte'; import CommentForm from '../comments/CommentForm.svelte';
import { getProfile } from '../../services/cache/profile-cache.js'; import { getProfile } from '../../services/cache/profile-cache.js';
import MediaViewer from '../../components/content/MediaViewer.svelte';
import { getEventLink } from '../../services/event-links.js';
import Icon from '../../components/ui/Icon.svelte';
// Media gallery state
let mediaViewerOpen = $state(false);
let mediaViewerUrl = $state<string | null>(null);
let selectedMediaEvent = $state<NostrEvent | null>(null);
interface MediaItem {
url: string;
type: 'image' | 'video' | 'audio';
thumbnailUrl?: string;
event: NostrEvent;
title?: string;
summary?: string;
alt?: string;
content?: string;
}
let profile = $state<ProfileData | null>(null); let profile = $state<ProfileData | null>(null);
let profileEvent = $state<NostrEvent | null>(null); // The kind 0 event let profileEvent = $state<NostrEvent | null>(null); // The kind 0 event
@ -33,6 +51,181 @@
let loadingWall = $state(false); let loadingWall = $state(false);
let loadingMedia = $state(false); let loadingMedia = $state(false);
let activeTab = $state<'pins' | 'media' | 'notifications' | 'interactions' | 'wall'>('pins'); let activeTab = $state<'pins' | 'media' | 'notifications' | 'interactions' | 'wall'>('pins');
// Extract media items from media events
function extractMediaItems(events: NostrEvent[]): MediaItem[] {
const items: MediaItem[] = [];
const seen = new Set<string>();
for (const event of events) {
// Extract metadata from event tags
const titleTag = event.tags.find((t) => t[0] === 'title');
const summaryTag = event.tags.find((t) => t[0] === 'summary');
const altTag = event.tags.find((t) => t[0] === 'alt');
const title = titleTag?.[1] || undefined;
const summary = summaryTag?.[1] || undefined;
const alt = altTag?.[1] || undefined;
const content = event.content || undefined;
// Normalize URL helper
function normalizeUrl(url: string): string {
try {
const parsed = new URL(url);
return `${parsed.protocol}//${parsed.host.toLowerCase()}${parsed.pathname}`.replace(/\/$/, '');
} catch {
return url.trim().replace(/\/$/, '').toLowerCase();
}
}
// Parse imeta tag
function parseImetaTag(tag: string[]): {
url?: string;
mimeType?: string;
thumbnailUrl?: string;
} {
let url: string | undefined;
let mimeType: string | undefined;
let thumbnailUrl: string | undefined;
for (let i = 1; i < tag.length; i++) {
const item = tag[i];
if (item.startsWith('url ')) {
url = item.substring(4).trim();
} else if (item.startsWith('m ')) {
mimeType = item.substring(2).trim();
} else if (item.startsWith('thumb ')) {
thumbnailUrl = item.substring(6).trim();
}
}
return { url, mimeType, thumbnailUrl };
}
// Get imeta for URL
function getImetaForUrl(url: string): { mimeType?: string; thumbnailUrl?: string } | null {
const normalized = normalizeUrl(url);
for (const tag of event.tags) {
if (tag[0] === 'imeta') {
const imeta = parseImetaTag(tag);
if (imeta.url && normalizeUrl(imeta.url) === normalized) {
return { mimeType: imeta.mimeType, thumbnailUrl: imeta.thumbnailUrl };
}
}
}
return null;
}
// 1. Check image tag
const imageTag = event.tags.find((t) => t[0] === 'image');
if (imageTag && imageTag[1]) {
const normalized = normalizeUrl(imageTag[1]);
if (!seen.has(normalized)) {
const imeta = getImetaForUrl(imageTag[1]);
let type: 'image' | 'video' | 'audio' = 'image';
if (imeta?.mimeType) {
if (imeta.mimeType.startsWith('video/')) type = 'video';
else if (imeta.mimeType.startsWith('audio/')) type = 'audio';
}
items.push({
url: imageTag[1],
type,
thumbnailUrl: imeta?.thumbnailUrl,
event,
title,
summary,
alt,
content
});
seen.add(normalized);
}
}
// 2. Check imeta tags
for (const tag of event.tags) {
if (tag[0] === 'imeta') {
const imeta = parseImetaTag(tag);
if (imeta.url) {
const normalized = normalizeUrl(imeta.url);
if (!seen.has(normalized)) {
let type: 'image' | 'video' | 'audio' = 'image';
if (imeta.mimeType) {
if (imeta.mimeType.startsWith('video/')) type = 'video';
else if (imeta.mimeType.startsWith('audio/')) type = 'audio';
}
items.push({
url: imeta.url,
type,
thumbnailUrl: imeta.thumbnailUrl,
event,
title,
summary,
alt,
content
});
seen.add(normalized);
}
}
}
}
// 3. Extract from content (plain URLs)
const urlRegex = /(https?:\/\/[^\s<>"{}|\\^`\[\]]+\.(jpg|jpeg|png|gif|webp|svg|bmp|mp4|webm|ogg|mov|avi|mkv|mp3|wav|flac|aac|m4a)(\?[^\s<>"{}|\\^`\[\]]*)?)/gi;
urlRegex.lastIndex = 0;
let match;
while ((match = urlRegex.exec(event.content)) !== null) {
const url = match[1];
const normalized = normalizeUrl(url);
if (!seen.has(normalized)) {
const ext = match[2].toLowerCase();
let type: 'image' | 'video' | 'audio' = 'image';
if (['mp4', 'webm', 'ogg', 'mov', 'avi', 'mkv'].includes(ext)) {
type = 'video';
} else if (['mp3', 'wav', 'ogg', 'flac', 'aac', 'm4a'].includes(ext)) {
type = 'audio';
}
const imeta = getImetaForUrl(url);
if (imeta?.mimeType) {
if (imeta.mimeType.startsWith('video/')) type = 'video';
else if (imeta.mimeType.startsWith('audio/')) type = 'audio';
}
items.push({
url,
type,
thumbnailUrl: imeta?.thumbnailUrl,
event,
title,
summary,
alt,
content
});
seen.add(normalized);
}
}
}
return items;
}
// Get all media items from media events
let mediaItems = $derived(extractMediaItems(mediaEvents));
function openMediaViewer(item: MediaItem) {
mediaViewerUrl = item.url;
selectedMediaEvent = item.event;
mediaViewerOpen = true;
}
function closeMediaViewer() {
mediaViewerOpen = false;
mediaViewerUrl = null;
selectedMediaEvent = null;
}
function viewEvent(event: NostrEvent) {
// Store event in sessionStorage so the event page can use it without re-fetching
sessionStorage.setItem('aitherboard_preloadedEvent', JSON.stringify(event));
goto(getEventLink(event));
}
let nip05Validations = $state<Record<string, boolean | null>>({}); // null = checking, true = valid, false = invalid let nip05Validations = $state<Record<string, boolean | null>>({}); // null = checking, true = valid, false = invalid
// Compute pubkey from route params // Compute pubkey from route params
let profilePubkey = $derived.by(() => decodePubkey($page.params.pubkey)); let profilePubkey = $derived.by(() => decodePubkey($page.params.pubkey));
@ -173,7 +366,7 @@
async function loadProfileEvent(pubkey: string) { async function loadProfileEvent(pubkey: string) {
if (!isMounted) return; if (!isMounted) return;
try { try {
// Check sessionStorage for a preloaded kind 0 event (from ProfileBadge click) // PRIORITY 1: Check sessionStorage for a preloaded kind 0 event (from ProfileBadge click) - INSTANT
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
const preloadedEventStr = sessionStorage.getItem('aitherboard_preloadedProfileEvent'); const preloadedEventStr = sessionStorage.getItem('aitherboard_preloadedProfileEvent');
if (preloadedEventStr) { if (preloadedEventStr) {
@ -181,32 +374,34 @@
const preloadedEvent = JSON.parse(preloadedEventStr) as NostrEvent; const preloadedEvent = JSON.parse(preloadedEventStr) as NostrEvent;
// Verify the event is a kind 0 event and matches the pubkey // Verify the event is a kind 0 event and matches the pubkey
if (preloadedEvent.kind === KIND.METADATA && preloadedEvent.pubkey === pubkey) { if (preloadedEvent.kind === KIND.METADATA && preloadedEvent.pubkey === pubkey) {
// Use the preloaded event // Use the preloaded event INSTANTLY
profileEvent = preloadedEvent; profileEvent = preloadedEvent;
// Clear it after reading // Clear it after reading
sessionStorage.removeItem('aitherboard_preloadedProfileEvent'); sessionStorage.removeItem('aitherboard_preloadedProfileEvent');
// Don't load wall comments here - load them when Wall tab is clicked // Don't load wall comments here - load them when Wall tab is clicked
return; return;
} else {
// Wrong pubkey - clear it
sessionStorage.removeItem('aitherboard_preloadedProfileEvent');
} }
} catch (parseError) { } catch (parseError) {
// Invalid JSON in sessionStorage, continue with normal loading // Invalid JSON in sessionStorage, continue with normal loading
console.warn('Failed to parse preloaded profile event from sessionStorage:', parseError);
sessionStorage.removeItem('aitherboard_preloadedProfileEvent'); sessionStorage.removeItem('aitherboard_preloadedProfileEvent');
} }
} }
} }
// Try cache first // PRIORITY 2: Try cache (very fast)
const cached = await getProfile(pubkey); const cached = await getProfile(pubkey);
if (cached) { if (cached && cached.event) {
profileEvent = cached.event; profileEvent = cached.event;
// Don't load wall comments here - load them when Wall tab is clicked // Don't load wall comments here - load them when Wall tab is clicked
return; return;
} }
// Fetch from relays if not in cache - shorter timeout // PRIORITY 3: Fetch from relays with cache-first (should be fast if in cache)
// Include profile owner's relay lists // Use default relays for fastest response (don't wait for relay lists)
const relays = await getProfileRelaysForPubkey(pubkey); const relays = relayManager.getProfileReadRelays();
const events = await nostrClient.fetchEvents( const events = await nostrClient.fetchEvents(
[{ kinds: [KIND.METADATA], authors: [pubkey], limit: 1 }], [{ kinds: [KIND.METADATA], authors: [pubkey], limit: 1 }],
relays, relays,
@ -373,12 +568,13 @@
async function checkMediaAvailability(pubkey: string) { async function checkMediaAvailability(pubkey: string) {
if (!isMounted) return; if (!isMounted) return;
try { try {
const profileRelays = relayManager.getProfileReadRelays(); // Use feed relays for media events (kinds 20, 21, 22 are feed-like)
const feedRelays = relayManager.getFeedReadRelays();
// Check if any events of kinds 20, 21, 22 exist for this pubkey // Check if any events of kinds 20, 21, 22 exist for this pubkey
const mediaCheck = await nostrClient.fetchEvents( const mediaCheck = await nostrClient.fetchEvents(
[{ kinds: [KIND.PICTURE_NOTE, KIND.VIDEO_NOTE, KIND.SHORT_VIDEO_NOTE], authors: [pubkey], limit: 1 }], [{ kinds: [KIND.PICTURE_NOTE, KIND.VIDEO_NOTE, KIND.SHORT_VIDEO_NOTE], authors: [pubkey], limit: 1 }],
profileRelays, feedRelays,
{ useCache: 'cache-first', cacheResults: true, timeout: config.shortTimeout } { useCache: 'cache-first', cacheResults: true, timeout: config.mediumTimeout }
); );
if (!isMounted) return; if (!isMounted) return;
@ -393,14 +589,15 @@
if (!isMounted) return; if (!isMounted) return;
loadingMedia = true; loadingMedia = true;
try { try {
const profileRelays = relayManager.getFeedReadRelays(); // Use feed relays for media events (kinds 20, 21, 22 are feed-like)
const feedRelays = relayManager.getFeedReadRelays();
const fetchPromise = nostrClient.fetchEvents( const fetchPromise = nostrClient.fetchEvents(
[{ kinds: [KIND.PICTURE_NOTE, KIND.VIDEO_NOTE, KIND.SHORT_VIDEO_NOTE], authors: [pubkey], limit: config.feedLimit }], [{ kinds: [KIND.PICTURE_NOTE, KIND.VIDEO_NOTE, KIND.SHORT_VIDEO_NOTE], authors: [pubkey], limit: config.feedLimit }],
profileRelays, feedRelays,
{ {
useCache: 'cache-first', useCache: 'cache-first',
cacheResults: true, cacheResults: true,
timeout: config.shortTimeout, timeout: config.mediumTimeout,
onUpdate: (newMedia) => { onUpdate: (newMedia) => {
if (!isMounted) return; if (!isMounted) return;
// Merge with existing media // Merge with existing media
@ -758,25 +955,74 @@
loading = true; loading = true;
try { try {
// Step 0: Get relays including profile owner's relay lists (non-blocking, with timeout) // CRITICAL: Check sessionStorage SYNCHRONOUSLY first (before any async operations)
const profileRelaysPromise = getProfileRelaysForPubkey(pubkey); // This ensures instant loading when coming from ProfileBadge click
if (typeof window !== 'undefined') {
const preloadedEventStr = sessionStorage.getItem('aitherboard_preloadedProfileEvent');
if (preloadedEventStr) {
try {
const preloadedEvent = JSON.parse(preloadedEventStr) as NostrEvent;
// Verify the event is a kind 0 event and matches the pubkey
if (preloadedEvent.kind === KIND.METADATA && preloadedEvent.pubkey === pubkey) {
// Use the preloaded event INSTANTLY - set it synchronously
profileEvent = preloadedEvent;
// Clear it after reading
sessionStorage.removeItem('aitherboard_preloadedProfileEvent');
} else {
// Wrong pubkey - clear it
sessionStorage.removeItem('aitherboard_preloadedProfileEvent');
}
} catch (parseError) {
// Invalid JSON in sessionStorage, clear it
sessionStorage.removeItem('aitherboard_preloadedProfileEvent');
}
}
}
// Step 0: Load profile event from cache (if not already set from sessionStorage)
// This is critical for fast loading - check cache immediately
const profileEventPromise = profileEvent ? Promise.resolve() : loadProfileEvent(pubkey);
// Step 1: Load profile and status first (fast from cache) - display immediately // Step 1: Load profile and status from cache first (fast) - display immediately
// fetchProfile uses parseProfile which prioritizes tags over JSON content // fetchProfile uses parseProfile which prioritizes tags over JSON content
// Use profile owner's relays if available, otherwise fall back to default // Use default relays for cache-first loading (fastest)
const relaysPromise = profileRelaysPromise.catch(() => relayManager.getProfileReadRelays()); const relays = relayManager.getProfileReadRelays();
const relays = await relaysPromise;
const profilePromise = fetchProfile(pubkey, relays); const profilePromise = fetchProfile(pubkey, relays);
const statusPromise = fetchUserStatus(pubkey, relays); const statusPromise = fetchUserStatus(pubkey, relays);
const statusEventPromise = fetchUserStatusEvent(pubkey, relays); const statusEventPromise = fetchUserStatusEvent(pubkey, relays);
// Load pins from cache FIRST (very fast)
const pinsPromise = loadPins(pubkey);
// Check media availability from cache FIRST (very fast)
const mediaCheckPromise = checkMediaAvailability(pubkey);
// Wait for all cache-first loads in parallel
activeFetchPromises.add(profilePromise); activeFetchPromises.add(profilePromise);
activeFetchPromises.add(statusPromise); activeFetchPromises.add(statusPromise);
activeFetchPromises.add(statusEventPromise); activeFetchPromises.add(statusEventPromise);
const [profileData, status, statusEvent] = await Promise.all([profilePromise, statusPromise, statusEventPromise]); activeFetchPromises.add(profileEventPromise);
activeFetchPromises.add(pinsPromise);
activeFetchPromises.add(mediaCheckPromise);
const [profileData, status, statusEvent] = await Promise.all([
profilePromise,
statusPromise,
statusEventPromise
]);
// Wait for background cache loads (don't block on these)
Promise.all([profileEventPromise, pinsPromise, mediaCheckPromise]).catch(() => {
// Background loads failed - non-critical
});
activeFetchPromises.delete(profilePromise); activeFetchPromises.delete(profilePromise);
activeFetchPromises.delete(statusPromise); activeFetchPromises.delete(statusPromise);
activeFetchPromises.delete(statusEventPromise); activeFetchPromises.delete(statusEventPromise);
activeFetchPromises.delete(profileEventPromise);
activeFetchPromises.delete(pinsPromise);
activeFetchPromises.delete(mediaCheckPromise);
// Check if this load was aborted or if pubkey changed // Check if this load was aborted or if pubkey changed
if (!isMounted || abortController.signal.aborted || currentLoadPubkey !== pubkey) { if (!isMounted || abortController.signal.aborted || currentLoadPubkey !== pubkey) {
@ -801,21 +1047,11 @@
} }
} }
// Load pins and notifications/interactions in parallel (non-blocking) // Load notifications/interactions in parallel (non-blocking, cache-first)
// Don't wait for these - they'll update the UI as they load // Pins and media check already loaded above
const loadPromises: Promise<void>[] = []; const loadPromises: Promise<void>[] = [];
// Always load pins (for both own and other profiles) // Load notifications or interactions based on profile type (cache-first)
loadPromises.push(loadPins(pubkey).catch(() => {
// Failed to load pins - non-critical
}));
// Check if media (kinds 20, 21, 22) are available
loadPromises.push(checkMediaAvailability(pubkey).catch(() => {
// Failed to check media - non-critical
}));
// Load notifications or interactions based on profile type
if (isOwnProfile) { if (isOwnProfile) {
loadPromises.push(loadNotifications(pubkey).catch(() => { loadPromises.push(loadNotifications(pubkey).catch(() => {
// Failed to load notifications - non-critical // Failed to load notifications - non-critical
@ -823,7 +1059,7 @@
interactionsWithMe = []; interactionsWithMe = [];
} else { } else {
notifications = []; notifications = [];
// Load interactions if logged in and viewing another user's profile // Load interactions if logged in and viewing another user's profile (cache-first)
if (currentUserPubkey) { if (currentUserPubkey) {
loadPromises.push(loadInteractionsWithMe(pubkey, currentUserPubkey).catch(() => { loadPromises.push(loadInteractionsWithMe(pubkey, currentUserPubkey).catch(() => {
// Failed to load interactions - non-critical // Failed to load interactions - non-critical
@ -851,12 +1087,6 @@
} }
} }
}); });
// Load profile event in background (only needed for wall tab)
// Don't await - load it when user clicks Wall tab
loadProfileEvent(pubkey).catch(() => {
// Failed to load profile event - non-critical
});
} catch (error) { } catch (error) {
// Only update state if this load wasn't aborted // Only update state if this load wasn't aborted
if (!abortController.signal.aborted && currentLoadPubkey === pubkey) { if (!abortController.signal.aborted && currentLoadPubkey === pubkey) {
@ -1025,14 +1255,85 @@
{:else if activeTab === 'media'} {:else if activeTab === 'media'}
{#if loadingMedia} {#if loadingMedia}
<p class="text-fog-text-light dark:text-fog-dark-text-light">Loading media...</p> <p class="text-fog-text-light dark:text-fog-dark-text-light">Loading media...</p>
{:else if mediaEvents.length === 0} {:else if mediaItems.length === 0}
<p class="text-fog-text-light dark:text-fog-dark-text-light">No media posts yet.</p> <p class="text-fog-text-light dark:text-fog-dark-text-light">No media posts yet.</p>
{:else} {:else}
<div class="media-list"> <div class="media-gallery">
{#each mediaEvents as media (media.id)} {#each mediaItems as item (item.url)}
<FeedPost post={media} /> <div
class="media-gallery-item"
role="button"
tabindex="0"
onclick={() => openMediaViewer(item)}
onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); openMediaViewer(item); } }}
>
{#if item.type === 'image'}
<img
src={item.thumbnailUrl || item.url}
alt={item.alt || 'Media'}
class="media-thumbnail"
loading="lazy"
/>
{:else if item.type === 'video'}
<div class="media-thumbnail video-thumbnail">
{#if item.thumbnailUrl}
<img src={item.thumbnailUrl} alt={item.alt || 'Video thumbnail'} class="video-thumb-img" loading="lazy" />
{:else}
<video
src={item.url}
class="video-thumb-video"
preload="metadata"
muted
playsinline
onloadeddata={(e) => {
const video = e.currentTarget as HTMLVideoElement;
video.currentTime = 0.1;
}}
></video>
<div class="video-placeholder-bg"></div>
{/if}
<div class="video-play-overlay"></div>
</div>
{:else}
<div class="media-thumbnail audio-thumbnail">
<div class="audio-placeholder">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12 3v10.55c-.59-.34-1.27-.55-2-.55-2.21 0-4 1.79-4 4s1.79 4 4 4 4-1.79 4-4V7h4V3h-6z" fill="currentColor"/>
</svg>
</div>
</div>
{/if}
<div class="media-info">
{#if item.title}
<div class="media-title">{item.title}</div>
{/if}
{#if item.summary}
<div class="media-summary">{item.summary}</div>
{/if}
{#if item.alt && !item.title && !item.summary}
<div class="media-alt">{item.alt}</div>
{/if}
{#if item.content && !item.title && !item.summary && !item.alt}
<div class="media-content">{item.content}</div>
{/if}
</div>
<div
class="media-overlay"
role="button"
tabindex="0"
onclick={(e) => { e.stopPropagation(); viewEvent(item.event); }}
onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); e.stopPropagation(); viewEvent(item.event); } }}
>
<button class="media-view-event-btn" title="View event" type="button">
<Icon name="eye" size={16} />
</button>
</div>
</div>
{/each} {/each}
</div> </div>
{#if mediaViewerUrl && mediaViewerOpen}
<MediaViewer url={mediaViewerUrl} isOpen={mediaViewerOpen} onClose={closeMediaViewer} />
{/if}
{/if} {/if}
{:else if activeTab === 'notifications'} {:else if activeTab === 'notifications'}
{#if notifications.length === 0} {#if notifications.length === 0}
@ -1349,4 +1650,235 @@
max-width: 100%; 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;
}
</style> </style>

17
src/lib/services/cache/indexeddb-store.ts vendored

@ -5,7 +5,7 @@
import { openDB, type IDBPDatabase } from 'idb'; import { openDB, type IDBPDatabase } from 'idb';
const DB_NAME = 'aitherboard'; 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 { export interface DatabaseSchema {
events: { events: {
@ -58,6 +58,11 @@ export interface DatabaseSchema {
value: unknown; value: unknown;
indexes: { eventKey: string; pubkey: string; kind: number; savedAt: number }; indexes: { eventKey: string; pubkey: string; kind: number; savedAt: number };
}; };
media: {
key: string; // normalized URL
value: unknown;
indexes: { cached_at: number };
};
} }
let dbInstance: IDBPDatabase<DatabaseSchema> | null = null; let dbInstance: IDBPDatabase<DatabaseSchema> | null = null;
@ -150,6 +155,12 @@ export async function getDB(): Promise<IDBPDatabase<DatabaseSchema>> {
eventVersionsStore.createIndex('kind', 'kind', { unique: false }); eventVersionsStore.createIndex('kind', 'kind', { unique: false });
eventVersionsStore.createIndex('savedAt', 'savedAt', { 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() { blocked() {
// IndexedDB blocked (another tab may have it open) // IndexedDB blocked (another tab may have it open)
@ -202,7 +213,7 @@ export async function getDB(): Promise<IDBPDatabase<DatabaseSchema>> {
throw new Error('Failed to open database'); throw new Error('Failed to open database');
} }
const db = dbInstance; // TypeScript narrowing helper 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)); const missingStores = criticalStores.filter(store => !db.objectStoreNames.contains(store));
if (missingStores.length > 0) { if (missingStores.length > 0) {
@ -262,6 +273,8 @@ export async function getDB(): Promise<IDBPDatabase<DatabaseSchema>> {
eventVersionsStore.createIndex('pubkey', 'pubkey', { unique: false }); eventVersionsStore.createIndex('pubkey', 'pubkey', { unique: false });
eventVersionsStore.createIndex('kind', 'kind', { unique: false }); eventVersionsStore.createIndex('kind', 'kind', { unique: false });
eventVersionsStore.createIndex('savedAt', 'savedAt', { unique: false }); eventVersionsStore.createIndex('savedAt', 'savedAt', { unique: false });
const mediaStore = db.createObjectStore('media', { keyPath: 'url' });
mediaStore.createIndex('cached_at', 'cached_at', { unique: false });
}, },
blocked() { blocked() {
// IndexedDB blocked (another tab may have it open) // IndexedDB blocked (another tab may have it open)

167
src/lib/services/cache/media-cache.ts vendored

@ -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<Blob | null> {
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<void> {
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<string> {
// 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<void> {
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 };
}
}

11
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'); 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); const audioUrl = URL.createObjectURL(audioBlob);
this.setupAudioElement(audioUrl, options?.volume ?? 1.0); this.setupAudioElement(audioUrl, options?.volume ?? 1.0);
await this.audioElement!.play(); await this.audioElement!.play();

Loading…
Cancel
Save