diff --git a/src/lib/js/pip.svelte.ts b/src/lib/js/pip.svelte.ts index 9906866..1d46577 100644 --- a/src/lib/js/pip.svelte.ts +++ b/src/lib/js/pip.svelte.ts @@ -10,6 +10,7 @@ interface PipOptions { initialCorner?: Corner; smallHeight?: number; largeHeight?: number; + fullscreenEnabled?: boolean; } type Corner = 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right'; @@ -17,13 +18,16 @@ type Corner = 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right'; export function createPip(opts: PipOptions = {}) { const margin = opts.margin ?? 16; const tapThreshold = opts.tapThreshold ?? 10; - const doubleTapMs = opts.doubleTapMs ?? 400; + const doubleTapMs = opts.doubleTapMs ?? 250; const smallVh = opts.smallHeight ?? 25; const largeVh = opts.largeHeight ?? 37.5; + const fullscreenEnabled = opts.fullscreenEnabled ?? false; let corner: Corner = $state(opts.initialCorner ?? 'bottom-right'); let dragging = $state(false); let enlarged = $state(false); + let fullscreen = $state(false); + let showControls = $state(false); let dragOffset = { x: 0, y: 0 }; let dragPos = { x: 0, y: 0 }; @@ -59,7 +63,7 @@ export function createPip(opts: PipOptions = {}) { const pos = getCornerPos(c, target); corner = c; dragPos = pos; - target.style.transition = 'transform 0.25s ease'; + target.style.transition = 'transform 0.25s cubic-bezier(0.4, 0, 0.2, 1)'; target.style.transform = `translate(${pos.x}px, ${pos.y}px)`; target.addEventListener('transitionend', () => { target.style.transition = ''; }, { once: true }); } @@ -82,15 +86,77 @@ export function createPip(opts: PipOptions = {}) { if (corner.includes('bottom')) newY = rect.bottom - newH; dragPos = { x: newX, y: newY }; - el.style.transition = 'transform 0.25s ease'; + el.style.transition = 'transform 0.25s cubic-bezier(0.4, 0, 0.2, 1)'; el.style.transform = `translate(${newX}px, ${newY}px)`; el.addEventListener('transitionend', () => { el!.style.transition = ''; }, { once: true }); } + function toggleFullscreen() { + if (!el) return; + showControls = false; + lastTapTime = 0; // cancel any pending single-tap timeout + const img = el.querySelector('img') as HTMLImageElement | null; + + if (!fullscreen) { + // Enter fullscreen: dark bg appears, image grows + moves to center + fullscreen = true; + el.style.transition = 'transform 0.3s cubic-bezier(0.4, 0, 0.2, 1)'; + el.style.transform = 'translate(0px, 0px)'; + if (img) { + img.style.transition = 'height 0.3s cubic-bezier(0.4, 0, 0.2, 1)'; + img.style.height = '90vh'; + img.style.maxWidth = '95vw'; + img.style.maxHeight = '90vh'; + } + setTimeout(() => { + if (el) el.style.transition = ''; + if (img) img.style.transition = ''; + }, 350); + } else { + // Exit fullscreen: image shrinks + moves to corner, then bg removed + const vh = window.innerHeight; + const vw = window.innerWidth; + const pipH = vh * (smallVh / 100); + let pipW = pipH; + if (img && img.naturalHeight > 0) { + pipW = pipH * (img.naturalWidth / img.naturalHeight); + } + const pos = { + x: corner.includes('right') ? vw - pipW - margin : margin, + y: corner.includes('bottom') ? vh - pipH - margin : margin, + }; + dragPos = pos; + + el.style.transition = 'transform 0.3s cubic-bezier(0.4, 0, 0.2, 1)'; + el.style.transform = `translate(${pos.x}px, ${pos.y}px)`; + if (img) { + img.style.transition = 'height 0.3s cubic-bezier(0.4, 0, 0.2, 1)'; + img.style.height = `${smallVh}vh`; + img.style.maxWidth = ''; + img.style.maxHeight = ''; + } + + setTimeout(() => { + fullscreen = false; + enlarged = false; + if (el) el.style.transition = ''; + if (img) { + img.style.transition = ''; + img.style.height = ''; + } + }, 350); + } + } + function show(target: HTMLElement) { el = target; + showControls = false; + if (fullscreen) { + target.style.opacity = '1'; + return; + } const pos = getCornerPos(corner, target); dragPos = pos; target.style.transform = `translate(${pos.x}px, ${pos.y}px)`; @@ -98,11 +164,25 @@ export function createPip(opts: PipOptions = {}) { } function hide() { + if (fullscreen) { + fullscreen = false; + showControls = false; + if (el) { + const img = el.querySelector('img') as HTMLElement | null; + if (img) { + img.style.transition = ''; + img.style.height = ''; + img.style.maxWidth = ''; + img.style.maxHeight = ''; + } + el.style.transition = ''; + } + } if (el) el.style.opacity = '0'; } function reposition() { - if (!el) return; + if (!el || fullscreen) return; const pos = getCornerPos(corner, el); dragPos = pos; el.style.transform = `translate(${pos.x}px, ${pos.y}px)`; @@ -112,15 +192,19 @@ export function createPip(opts: PipOptions = {}) { if (!el) return; dragging = true; dragMoved = false; - const r = el.getBoundingClientRect(); - dragOffset = { x: e.clientX - r.left, y: e.clientY - r.top }; + + if (!fullscreen) { + const r = el.getBoundingClientRect(); + dragOffset = { x: e.clientX - r.left, y: e.clientY - r.top }; + el.style.transition = ''; + } + el.setPointerCapture(e.pointerId); - el.style.transition = ''; e.preventDefault(); } function onpointermove(e: PointerEvent) { - if (!dragging || !el) return; + if (!dragging || !el || fullscreen) return; const x = e.clientX - dragOffset.x; const y = e.clientY - dragOffset.y; if (!dragMoved) { @@ -140,23 +224,40 @@ export function createPip(opts: PipOptions = {}) { const now = Date.now(); if (now - lastTapTime < doubleTapMs) { lastTapTime = 0; - toggleEnlarged(); + if (fullscreen) { + toggleFullscreen(); // exit fullscreen + } else { + toggleEnlarged(); + } return; } lastTapTime = now; + if (fullscreenEnabled) { + // Delayed single tap: toggle controls (skipped if double-tap follows) + const tapTime = now; + setTimeout(() => { + if (lastTapTime === tapTime) showControls = !showControls; + }, doubleTapMs); + } + return; } - const r = el.getBoundingClientRect(); - snapToCorner(el, nearestCorner(r.left, r.top, el)); + if (!fullscreen) { + const r = el.getBoundingClientRect(); + snapToCorner(el, nearestCorner(r.left, r.top, el)); + } } return { get corner() { return corner; }, get dragging() { return dragging; }, get enlarged() { return enlarged; }, + get fullscreen() { return fullscreen; }, + get showControls() { return showControls; }, show, hide, reposition, + toggleFullscreen, onpointerdown, onpointermove, onpointerup diff --git a/src/routes/[faithLang=faithLang]/[rosary=rosaryLang]/+page.svelte b/src/routes/[faithLang=faithLang]/[rosary=rosaryLang]/+page.svelte index 25a9b26..0fb5c27 100644 --- a/src/routes/[faithLang=faithLang]/[rosary=rosaryLang]/+page.svelte +++ b/src/routes/[faithLang=faithLang]/[rosary=rosaryLang]/+page.svelte @@ -323,63 +323,65 @@ let activeSection = $state("cross"); let sectionElements = {}; let svgContainer; -// Whether the rosary has mystery images (stable, doesn't change during scroll) -const hasMysteryImages = $derived(showImages && selectedMystery === 'schmerzhaften'); +// Mystery images with captions (per mystery type, keyed by decade number 1-5) +const allMysteryImages = { + freudenreich: new Map([ + [1, { src: "/glaube/joyful/1-murilllo-annunciation.webp", artist: "Bartolomé Esteban Murillo", title: "The Annunciation", titleDe: "Die Verkündigung" }], + [2, { src: "/glaube/joyful/2-carl-bloch.the-visitation.1866.webp", artist: "Carl Bloch", title: "The Visitation", titleDe: "Die Heimsuchung", year: 1866 }], + [3, { src: "/glaube/joyful/3-adoration-of-the-shepards.webp", title: "Adoration of the Shepherds", titleDe: "Die Anbetung der Hirten" }], + [4, { src: "/glaube/joyful/4-vouet.presentation-in-the-temple.webp", artist: "Simon Vouet", title: "The Presentation in the Temple", titleDe: "Die Darstellung im Tempel" }], + [5, { src: "/glaube/joyful/5-carl-bloch.the-twelve-year-old-jesus-in-the-temple.1869.webp", artist: "Carl Bloch", title: "The Twelve-Year-Old Jesus in the Temple", titleDe: "Der zwölfjährige Jesus im Tempel", year: 1869 }], + ]), + schmerzhaften: new Map([ + [1, { src: "/glaube/sorrowful/1.carl-bloch.gethsemane.webp", artist: "Carl Bloch", title: "Gethsemane", titleDe: "Gethsemane", year: 1873 }], + [2, { src: "/glaube/sorrowful/2.wiliam-bouguereau.flagellation.webp", artist: "William-Adolphe Bouguereau", title: "The Flagellation of Our Lord Jesus Christ", titleDe: "Die Geisselung unseres Herrn Jesus Christus", year: 1880 }], + [3, { src: "/glaube/sorrowful/3.carl-bloch.mocking.webp", artist: "Carl Bloch", title: "The Mocking of Christ", titleDe: "Die Verspottung Christi", year: 1880 }], + [4, { src: "/glaube/sorrowful/4.lorenzo-lotto.carrying-the-cross.webp", artist: "Lorenzo Lotto", title: "Carrying the Cross", titleDe: "Die Kreuztragung", year: 1526 }], + [5, { src: "/glaube/sorrowful/5.alonso-cano.the-crucifixion.webp", artist: "Diego Velázquez", title: "Christ Crucified", titleDe: "Der gekreuzigte Christus", year: 1632 }], + ]), + glorreichen: new Map([ + [1, { src: "/glaube/glorious/1-carl-bloch.resurrection.webp", artist: "Carl Bloch", title: "The Resurrection", titleDe: "Die Auferstehung" }], + [2, { src: "/glaube/glorious/2-ascension.webp", title: "The Ascension", titleDe: "Die Himmelfahrt" }], + [3, { src: "/glaube/glorious/3-pentecost.webp", title: "Pentecost", titleDe: "Die Geistsendung" }], + [4, { src: "/glaube/glorious/4-giovanni-tiepolo.the-immaculate-conception.webp", artist: "Giovanni Battista Tiepolo", title: "The Immaculate Conception", titleDe: "Die Aufnahme Mariens in den Himmel" }], + [5, { src: "/glaube/glorious/5-diego-veazquez.coronation-mary.webp", artist: "Diego Velázquez", title: "Coronation of the Virgin", titleDe: "Die Krönung der Jungfrau", year: 1641 }], + ]), + lichtreichen: new Map([ + [1, { src: "/glaube/luminous/1-carl-bloch.the-baptism-of-christ.1870.webp", artist: "Carl Bloch", title: "The Baptism of Christ", titleDe: "Die Taufe Christi", year: 1870 }], + [2, { src: "/glaube/luminous/2-carl-bloch.the-wedding-at-cana.1870.webp", artist: "Carl Bloch", title: "The Wedding at Cana", titleDe: "Die Hochzeit zu Kana", year: 1870 }], + [3, { src: "/glaube/luminous/3-carl-bloch.the-sermon-on-the-mount.1877.jpg", artist: "Carl Bloch", title: "The Sermon on the Mount", titleDe: "Die Bergpredigt", year: 1877 }], + [4, { src: "/glaube/luminous/4-carl-bloch.transfiguration-of-christ.webp", artist: "Carl Bloch", title: "Transfiguration of Christ", titleDe: "Die Verklärung Christi" }], + [5, { src: "/glaube/luminous/5-carl-bloch.the-last-supper.webp", artist: "Carl Bloch", title: "The Last Supper", titleDe: "Das letzte Abendmahl" }], + ]), +}; -// Mystery image scroll target based on active section +// Whether the rosary has mystery images (stable, doesn't change during scroll) +const hasMysteryImages = $derived(showImages && (allMysteryImages[selectedMystery]?.size ?? 0) > 0); + +// Mystery image scroll target based on active section (returns decade number 1-5, or 'before'/'after') function getMysteryScrollTarget(section) { - switch (section) { - case 'cross': - return 'before'; - case 'lbead2': - return 'garden'; - case 'secret1': - return 'garden'; - case 'secret1_transition': - return 'flagellation'; - case 'secret2': - return 'flagellation'; - case 'secret2_transition': - return 'mocking'; - case 'secret3': - return 'mocking'; - case 'secret3_transition': - return 'carry'; - case 'secret4': - return 'carry'; - case 'secret4_transition': - case 'secret5': - return 'crucifixion'; - case 'final_transition': - case 'final_salve': - case 'final_schlussgebet': - case 'final_michael': - case 'final_paternoster': - case 'final_cross': - return 'after'; - default: - return 'before'; + if (section === 'lbead2') return 1; + const secretMatch = section.match(/^secret(\d)/); + if (secretMatch) { + const num = parseInt(secretMatch[1]); + return section.includes('_transition') ? num + 1 : num; } + if (section.startsWith('final_')) return 'after'; + return 'before'; } -// Mystery images with captions -const mysteryImages = new Map([ - ["garden", { src: "/glaube/sorrowful/1.carl-bloch.gethsemane.webp", artist: "Carl Bloch", title: "Gethsemane", titleDe: "Gethsemane", year: 1873 }], - ["flagellation", { src: "/glaube/sorrowful/2.wiliam-bouguereau.flagellation.webp", artist: "William-Adolphe Bouguereau", title: "The Flagellation of Our Lord Jesus Christ", titleDe: "Die Geisselung unseres Herrn Jesus Christus", year: 1880 }], - ["mocking", { src: "/glaube/sorrowful/3.carl-bloch.mocking.webp", artist: "Carl Bloch", title: "The Mocking of Christ", titleDe: "Die Verspottung Christi", year: 1880 }], - ["carry", { src: "/glaube/sorrowful/4.lorenzo-lotto.carrying-the-cross.webp", artist: "Lorenzo Lotto", title: "Carrying the Cross", titleDe: "Die Kreuztragung", year: 1526 }], - ["crucifixion", { src: "/glaube/sorrowful/5.alonso-cano.the-crucifixion.webp", artist: "Diego Velázquez", title: "Christ Crucified", titleDe: "Der gekreuzigte Christus", year: 1632 }] -]); // Mobile PiP: which image src to show (null = hide) function getMysteryImage(mystery, section) { - if (mystery !== 'schmerzhaften') return null; + const images = allMysteryImages[mystery]; + if (!images || images.size === 0) return null; const target = getMysteryScrollTarget(section); - return mysteryImages.get(target)?.src ?? null; + if (target === 'before' || target === 'after') return null; + return images.get(target)?.src ?? null; } const mysteryPipSrc = $derived(getMysteryImage(selectedMystery, activeSection)); // Mobile PiP drag/enlarge -const pip = createPip(); +const pip = createPip({ fullscreenEnabled: true }); let rosaryPipEl = $state(null); let lastPipSrc = $state(null); @@ -1487,6 +1489,54 @@ h1 { .mystery-pip.enlarged img { height: 37.5vh; } +.mystery-pip.fullscreen { + width: 100vw; + height: 100vh; + background: rgba(0, 0, 0, 0.95); + display: flex; + align-items: center; + justify-content: center; + cursor: default; +} +.mystery-pip.fullscreen img { + border-radius: 0; + box-shadow: none; +} +.pip-fullscreen-btn { + all: unset; + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + background: transparent; + filter: drop-shadow(0 0 1px black); + width: 48px; + height: 48px; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + padding: 0; + z-index: 1; + pointer-events: auto; + outline: none; + transition: transform 0.15s ease; +} +.pip-fullscreen-btn:hover, +.pip-fullscreen-btn:active { + transform: translate(-50%, -50%) scale(1.2); +} +.mystery-pip.fullscreen .pip-fullscreen-btn { + top: auto; + left: auto; + bottom: 10vw; + right: 10vw; + transform: none; +} +.mystery-pip.fullscreen .pip-fullscreen-btn:hover, +.mystery-pip.fullscreen .pip-fullscreen-btn:active { + transform: scale(0.85); +} @media (min-width: 1200px) { .mystery-pip { display: none; @@ -1891,13 +1941,13 @@ h1 {
{#if hasMysteryImages} + {@const images = allMysteryImages[selectedMystery]}
- {#each ["garden", "flagellation", "mocking", "carry", "crucifixion"] as target, i} - {@const img = mysteryImages.get(target)} + {#each [...images.entries()] as [num, img], i} {#if i > 0}
{/if} -
- {img.artist} — {isEnglish ? img.title : img.titleDe} -
{img.artist}, {isEnglish ? img.title : img.titleDe}, {img.year}
+
+ {img.artist ? `${img.artist} — ` : ''}{isEnglish ? img.title : img.titleDe} +
{#if img.artist}{img.artist}, {/if}{isEnglish ? img.title : img.titleDe}{#if img.year}, {img.year}{/if}
{/each}
@@ -1912,6 +1962,7 @@ h1 { class="mystery-pip" class:visible={!!mysteryPipSrc} class:enlarged={pip.enlarged} + class:fullscreen={pip.fullscreen} bind:this={rosaryPipEl} onpointerdown={pip.onpointerdown} onpointermove={pip.onpointermove} @@ -1920,6 +1971,20 @@ h1 { {#if lastPipSrc} pip.reposition()}> {/if} + {#if pip.showControls} + + {/if}
{/if} diff --git a/static/glaube/glorious/1-carl-bloch.resurrection.webp b/static/glaube/glorious/1-carl-bloch.resurrection.webp new file mode 100644 index 0000000..4224128 Binary files /dev/null and b/static/glaube/glorious/1-carl-bloch.resurrection.webp differ diff --git a/static/glaube/glorious/2-ascension.webp b/static/glaube/glorious/2-ascension.webp new file mode 100644 index 0000000..3b15fda Binary files /dev/null and b/static/glaube/glorious/2-ascension.webp differ diff --git a/static/glaube/glorious/3-pentecost.webp b/static/glaube/glorious/3-pentecost.webp new file mode 100644 index 0000000..3b8e4f6 Binary files /dev/null and b/static/glaube/glorious/3-pentecost.webp differ diff --git a/static/glaube/glorious/4-giovanni-tiepolo.the-immaculate-conception.webp b/static/glaube/glorious/4-giovanni-tiepolo.the-immaculate-conception.webp new file mode 100644 index 0000000..72d7fb8 Binary files /dev/null and b/static/glaube/glorious/4-giovanni-tiepolo.the-immaculate-conception.webp differ diff --git a/static/glaube/glorious/5-diego-veazquez.coronation-mary.webp b/static/glaube/glorious/5-diego-veazquez.coronation-mary.webp new file mode 100644 index 0000000..7829b15 Binary files /dev/null and b/static/glaube/glorious/5-diego-veazquez.coronation-mary.webp differ diff --git a/static/glaube/joyful/1-murilllo-annunciation.webp b/static/glaube/joyful/1-murilllo-annunciation.webp new file mode 100644 index 0000000..1a84279 Binary files /dev/null and b/static/glaube/joyful/1-murilllo-annunciation.webp differ diff --git a/static/glaube/joyful/2-carl-bloch.the-visitation.1866.webp b/static/glaube/joyful/2-carl-bloch.the-visitation.1866.webp new file mode 100644 index 0000000..7e3c8ea Binary files /dev/null and b/static/glaube/joyful/2-carl-bloch.the-visitation.1866.webp differ diff --git a/static/glaube/joyful/3-adoration-of-the-shepards.webp b/static/glaube/joyful/3-adoration-of-the-shepards.webp new file mode 100644 index 0000000..baf864a Binary files /dev/null and b/static/glaube/joyful/3-adoration-of-the-shepards.webp differ diff --git a/static/glaube/joyful/4-vouet.presentation-in-the-temple.webp b/static/glaube/joyful/4-vouet.presentation-in-the-temple.webp new file mode 100644 index 0000000..0dfaedf Binary files /dev/null and b/static/glaube/joyful/4-vouet.presentation-in-the-temple.webp differ diff --git a/static/glaube/joyful/5-carl-bloch.the-twelve-year-old-jesus-in-the-temple.1869.webp b/static/glaube/joyful/5-carl-bloch.the-twelve-year-old-jesus-in-the-temple.1869.webp new file mode 100644 index 0000000..6ba5588 Binary files /dev/null and b/static/glaube/joyful/5-carl-bloch.the-twelve-year-old-jesus-in-the-temple.1869.webp differ diff --git a/static/glaube/luminous/1-carl-bloch.the-baptism-of-christ.1870.webp b/static/glaube/luminous/1-carl-bloch.the-baptism-of-christ.1870.webp new file mode 100644 index 0000000..2d2ae7a Binary files /dev/null and b/static/glaube/luminous/1-carl-bloch.the-baptism-of-christ.1870.webp differ diff --git a/static/glaube/luminous/2-carl-bloch.the-wedding-at-cana.1870.webp b/static/glaube/luminous/2-carl-bloch.the-wedding-at-cana.1870.webp new file mode 100644 index 0000000..ba416c7 Binary files /dev/null and b/static/glaube/luminous/2-carl-bloch.the-wedding-at-cana.1870.webp differ diff --git a/static/glaube/luminous/3-carl-bloch.the-sermon-on-the-mount.1877.jpg b/static/glaube/luminous/3-carl-bloch.the-sermon-on-the-mount.1877.jpg new file mode 100644 index 0000000..0e289ac Binary files /dev/null and b/static/glaube/luminous/3-carl-bloch.the-sermon-on-the-mount.1877.jpg differ diff --git a/static/glaube/luminous/4-carl-bloch.transfiguration-of-christ.webp b/static/glaube/luminous/4-carl-bloch.transfiguration-of-christ.webp new file mode 100644 index 0000000..313a2fe Binary files /dev/null and b/static/glaube/luminous/4-carl-bloch.transfiguration-of-christ.webp differ diff --git a/static/glaube/luminous/5-carl-bloch.the-last-supper.webp b/static/glaube/luminous/5-carl-bloch.the-last-supper.webp new file mode 100644 index 0000000..ae8e07a Binary files /dev/null and b/static/glaube/luminous/5-carl-bloch.the-last-supper.webp differ