Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions app/assets/js/nuxt-image/get-imgproxy-url.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { project } from '../../../../config/project'
import imgproxyProvider from './imgproxy.provider'

export function getImgproxyUrl(src: string) {
const { url } = imgproxyProvider().getImage(src, {
baseUrl: project.imgproxy.baseUrl,
internalProxyUrl: project.imgproxy.internalProxyUrl,
mediaProxyUrls: project.imgproxy.mediaProxyUrls
})

return url
}
186 changes: 115 additions & 71 deletions app/components/pages/posts/post/PostMedia.vue
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
<script lang="ts" setup>
import type { IPost, PostMediaType } from '~/assets/js/post.dto'
import { vIntersectionObserver } from '@vueuse/components'
import { getImgproxyUrl } from '~/assets/js/nuxt-image/get-imgproxy-url'
import { proxyUrl } from '~/assets/js/proxy'

const localePath = useLocalePath()
Expand Down Expand Up @@ -35,22 +36,48 @@

const mediaElement = shallowRef<MediaElementRef>(null)

const localSrc = shallowRef(props.mediaSrc ?? '')
const localPosterSrc = shallowRef(props.mediaPosterSrc ?? undefined)
const mediaUrl = shallowRef(props.mediaSrc ?? '')
const gifPosterUrl = shallowRef(props.mediaPosterSrc ?? undefined)
const videoPosterCorsFallbackUrl = shallowRef<string | null>(null)
const corsProxyRetriedForMedia = shallowRef(false)
const corsProxyRetriedForPoster = shallowRef(false)

const isLikelyLcpMedia = computed(() => props.postIndex === 0)

const shouldProxyPosterWithImgproxy = computed(
() => isPremium.value || (wasCurrentPageSSR.value && isLikelyLcpMedia.value)
)

const videoPosterUrl = computed(() => {
if (videoPosterCorsFallbackUrl.value) {
return videoPosterCorsFallbackUrl.value
}

const poster = props.mediaPosterSrc

if (!poster) {
return undefined
}

if (shouldProxyPosterWithImgproxy.value) {
return getImgproxyUrl(poster)
}

return poster
})

const error = ref<Error | null>(null)
const hasError = computed(() => error.value !== null)

const isImage = computed(() => props.mediaType === 'image')
const isVideo = computed(() => props.mediaType === 'video')
const isAnimatedMedia = computed(
() => props.mediaType === 'animated' || (props.mediaType === 'image' && localSrc.value.endsWith('.gif'))
() => props.mediaType === 'animated' || (props.mediaType === 'image' && mediaUrl.value.endsWith('.gif'))
)
const postImageSizes = {
sm: '400px',
md: '768px'
}
const isLikelyLcpMedia = computed(() => props.postIndex === 0)
const mediaDecoding = computed(() => (props.postIndex < 3 ? undefined : 'async'))
const mediaFetchPriority = computed(() => (isLikelyLcpMedia.value ? 'high' : undefined))
const mediaLoading = computed(() => (isLikelyLcpMedia.value ? 'eager' : 'lazy'))
Expand All @@ -61,15 +88,15 @@
props.mediaSrcWidth && props.mediaSrcHeight ? `${props.mediaSrcWidth}/${props.mediaSrcHeight}` : undefined
)
const lcpVideoPosterPreloadLinks = computed(() => {
if (!isLikelyLcpMedia.value || !isVideo.value || !localPosterSrc.value) {
if (!isLikelyLcpMedia.value || !isVideo.value || !videoPosterUrl.value) {
return []
}

return [
{
rel: 'preload',
as: 'image' as const,
href: localPosterSrc.value,
href: videoPosterUrl.value,
fetchpriority: 'high' as const
}
]
Expand All @@ -79,9 +106,6 @@
link: lcpVideoPosterPreloadLinks.value
}))

const triedToLoadWithProxy = shallowRef(false)
const triedToLoadPosterWithProxy = shallowRef(false)

let videoPlayer: FluidPlayerInstance | undefined
let videoPlayerInitPromise: Promise<void> | null = null
let videoPlayerIdleScheduled = false
Expand Down Expand Up @@ -199,7 +223,7 @@
},
{
label: t('media.download'),
href: localSrc.value
href: mediaUrl.value
}
]
},
Expand Down Expand Up @@ -227,7 +251,7 @@
return
}

currentVideoElement.src = localSrc.value
currentVideoElement.src = mediaUrl.value
videoPlayer?.play()
}
},
Expand Down Expand Up @@ -297,8 +321,6 @@
}

videoPlayer = fluidPlayer(initializedVideoElement, fluidPlayerOptions)

// TODO: Handle poster error
}

function initializeVideoPlayer() {
Expand Down Expand Up @@ -371,6 +393,58 @@
isAnimatedMediaPlaying.value = true
}

function tryCorsProxyRetryForVideoPoster(target: HTMLImageElement | HTMLVideoElement) {
if (!isVideo.value || !(target instanceof HTMLImageElement)) {
return
}

if (!isPremium.value || corsProxyRetriedForPoster.value || !props.mediaPosterSrc) {
return
}

videoPosterCorsFallbackUrl.value = proxyUrl(props.mediaPosterSrc)
corsProxyRetriedForPoster.value = true
}

function tryCorsProxyRetryForVideo(target: HTMLImageElement | HTMLVideoElement) {
if (!isVideo.value || !(target instanceof HTMLVideoElement)) {
return false
}

if (!isPremium.value || corsProxyRetriedForMedia.value || !props.mediaSrc) {
return false
}

corsProxyRetriedForMedia.value = true
mediaUrl.value = proxyUrl(props.mediaSrc)
void reloadVideoPlayer(true)
return true
}

function tryCorsProxyRetryForGif(target: HTMLImageElement | HTMLVideoElement) {
if (!isAnimatedMedia.value || !isPremium.value || !(target instanceof HTMLImageElement)) {
return false
}

if (!isAnimatedMediaPlaying.value && target.src === gifPosterUrl.value && !corsProxyRetriedForPoster.value) {
if (!gifPosterUrl.value) {
return false
}

gifPosterUrl.value = proxyUrl(gifPosterUrl.value)
corsProxyRetriedForPoster.value = true
return true
}

if (isAnimatedMediaPlaying.value && !corsProxyRetriedForMedia.value) {
mediaUrl.value = proxyUrl(mediaUrl.value)
corsProxyRetriedForMedia.value = true
return true
}

return false
}

function onMediaError(payload: string | Event) {
if (hasError.value) {
return
Expand All @@ -388,72 +462,32 @@
return
}

// Proxy videos
if (
isVideo.value &&
isPremium.value &&
//
!triedToLoadWithProxy.value
) {
localSrc.value = proxyUrl(localSrc.value)

reloadVideoPlayer(true)

triedToLoadWithProxy.value = true
return
}

// Reset loading state if there's an error with GIF
if (isAnimatedMedia.value && isAnimatedMediaPlaying.value) {
isAnimatedMediaLoading.value = false
}

// Proxy GIFs
if (isAnimatedMedia.value && isPremium.value) {
//

// Case 1: The poster image failed to load
if (
!isAnimatedMediaPlaying.value &&
target.src === localPosterSrc.value &&
//
!triedToLoadPosterWithProxy.value
) {
if (!localPosterSrc.value) {
return
}

localPosterSrc.value = proxyUrl(localPosterSrc.value)

triedToLoadPosterWithProxy.value = true
return
}

// Case 2: The actual GIF failed to load
if (
isAnimatedMediaPlaying.value &&
//
!triedToLoadWithProxy.value
) {
localSrc.value = proxyUrl(localSrc.value)
if (isVideo.value && target instanceof HTMLImageElement) {
tryCorsProxyRetryForVideoPoster(target)
return
}

triedToLoadWithProxy.value = true
return
}
if (tryCorsProxyRetryForVideo(target) || tryCorsProxyRetryForGif(target)) {
return
}

error.value = new Error(t('errors.mediaLoadError'))
}

function manuallyReloadMedia() {
// Reset state
triedToLoadWithProxy.value = false
triedToLoadPosterWithProxy.value = false
corsProxyRetriedForMedia.value = false
corsProxyRetriedForPoster.value = false
videoPosterCorsFallbackUrl.value = null
error.value = null

// Reload media
localSrc.value = props.mediaSrc ?? ''
localPosterSrc.value = props.mediaPosterSrc ?? undefined
mediaUrl.value = props.mediaSrc ?? ''
gifPosterUrl.value = props.mediaPosterSrc ?? undefined

if (isVideo.value) {
nextTick(() => {
Expand Down Expand Up @@ -622,7 +656,7 @@
:loading="mediaLoading"
:preload="mediaPreload"
:sizes="postImageSizes"
:src="localSrc"
:src="mediaUrl"
:width="mediaSrcWidthAttribute"
provider="imgproxy"
@error="onMediaError"
Expand All @@ -649,7 +683,7 @@
:loading="mediaLoading"
:preload="mediaPreload"
:sizes="postImageSizes"
:src="localSrc"
:src="mediaUrl"
:width="mediaSrcWidthAttribute"
provider="imgproxy"
@error="onMediaError"
Expand All @@ -667,7 +701,7 @@
:height="mediaSrcHeightAttribute"
:loading="mediaLoading"
:preload="mediaPreload"
:src="localSrc"
:src="mediaUrl"
:style="mediaAspectRatio ? `aspect-ratio: ${mediaAspectRatio};` : undefined"
:width="mediaSrcWidthAttribute"
class="h-auto w-full rounded-t-md"
Expand All @@ -690,7 +724,7 @@
:height="mediaSrcHeightAttribute"
:loading="mediaLoading"
:preload="mediaPreload"
:src="isAnimatedMediaPlaying ? localSrc : localPosterSrc"
:src="isAnimatedMediaPlaying ? mediaUrl : gifPosterUrl"
:style="mediaAspectRatio ? `aspect-ratio: ${mediaAspectRatio};` : undefined"
:width="mediaSrcWidthAttribute"
class="h-auto w-full rounded-t-md"
Expand Down Expand Up @@ -755,16 +789,26 @@
<!-- Video -->
<div
v-else-if="isVideo"
:key="localSrc"
:key="mediaUrl"
>
<!-- Detect poster load failures; video poster attr does not emit reliable error events. -->
<img
v-if="videoPosterUrl"
:src="videoPosterUrl"
alt=""
aria-hidden="true"
class="sr-only"
@error="onMediaError"
/>

<!-- TODO: Add load animation -->
<!-- Fix(rounded borders): add the same rounded borders that the parent has -->
<video
ref="mediaElement"
v-intersection-observer="[onVideoIntersectionObserver, { rootMargin: '100px' }]"
:height="mediaSrcHeightAttribute"
:poster="localPosterSrc"
:src="localSrc"
:poster="videoPosterUrl"
:src="mediaUrl"
:style="mediaAspectRatio ? `aspect-ratio: ${mediaAspectRatio};` : undefined"
:width="mediaSrcWidthAttribute"
class="h-auto w-full rounded-t-md"
Expand Down
30 changes: 30 additions & 0 deletions test/assets/get-imgproxy-url.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { describe, expect, it } from 'vitest'
import { getImgproxyUrl } from '../../app/assets/js/nuxt-image/get-imgproxy-url'

function decodeImgproxySource(url: string) {
const parsedUrl = URL.parse(url)

if (!parsedUrl) {
throw new Error(`Invalid imgproxy URL: ${url}`)
}

const encodedSource = parsedUrl.pathname.split('/').at(-1)

if (!encodedSource) {
throw new Error('Missing encoded imgproxy source')
}

const padded = encodedSource.padEnd(Math.ceil(encodedSource.length / 4) * 4, '=')
return Buffer.from(padded.replace(/-/g, '+').replace(/_/g, '/'), 'base64').toString('utf8')
}

describe('getImgproxyUrl', () => {
it('returns an imgproxy URL that fetches through the internal nginx proxy', () => {
const sourceUrl = 'https://example.local/thumbnails/998/thumbnail_998.jpg'

const proxiedUrl = getImgproxyUrl(sourceUrl)

expect(proxiedUrl.startsWith('https://imgproxy2.r34.app/')).toBe(true)
expect(decodeImgproxySource(proxiedUrl)).toBe(`http://nginx-proxy/proxy?url=${sourceUrl}`)
})
})
Loading