Skip to content
Merged
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
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@
"nuxt": "^4.4.5",
"typescript": "~6.0.3",
"vitest": "^4.1.5",
"vue": "^3.5.41",
"vue-tsc": "^3.2.8"
},
"publishConfig": {
Expand Down
3 changes: 3 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 8 additions & 0 deletions src/module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,14 @@ export default defineNuxtModule<ModuleOptions>({
name: 'useEmbedMetadata',
from: resolver.resolve('./runtime/composables/useEmbedMetadata'),
},
{
name: 'useFacadeActivation',
from: resolver.resolve('./runtime/composables/useFacadeActivation'),
},
{
name: 'useFrameRegistry',
from: resolver.resolve('./runtime/composables/useFrameRegistry'),
},
])
},
})
40 changes: 9 additions & 31 deletions src/runtime/components/InstagramCarousel.vue
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@
>
<div class="weburz-instagram-media">
<iframe
:ref="(el: Element | null) => bindIframe(el, index)"
:ref="(el: unknown) => bindIframe(el, index)"
class="weburz-instagram-embed"
:src="buildEmbedUrl(post.url)"
:title="post.title ?? `Instagram post ${index + 1}`"
Expand Down Expand Up @@ -119,6 +119,7 @@
import { computed, ref } from 'vue'
import type { EmblaOptionsType, EmblaPluginType } from 'embla-carousel'
import type { InstagramPost, SlidesPerView } from '../types'
import { useFrameRegistry } from '../composables/useFrameRegistry'
import { useScrollAwayHandler } from '../composables/useScrollAwayHandler'

interface Props {
Expand Down Expand Up @@ -178,8 +179,8 @@ const props = withDefaults(defineProps<Props>(), {
const rootEl = ref<HTMLElement | null>(null)
const activeIndex = ref(0)
const activePost = computed(() => props.posts[activeIndex.value])
const iframeEls = new Map<number, HTMLIFrameElement>()
const boundIframes = new Set<number>()

const { bind: bindIframe, park, restore, parkAll, restoreAll } = useFrameRegistry<number>()

// Instagram's direct embed URL bypasses embed.js entirely — works even when
// content blockers drop instagram.com/embed.js. We use /embed/captioned/ rather
Expand All @@ -200,30 +201,9 @@ const buildEmbedUrl = (url: string): string => {
}
}

const bindIframe = (el: Element | null, index: number) => {
if (!el || !(el instanceof HTMLIFrameElement)) return
iframeEls.set(index, el)
if (boundIframes.has(index)) return
boundIframes.add(index)
}

// IG has no postMessage control API, so pause = unload the iframe (src → about:blank).
// IG has no postMessage control API, so pause = park the iframe (src → about:blank).
// On scroll-back / swipe-back the original src is restored, which causes a brief
// reload of the embed. Acceptable trade-off; opt out via prop if it bothers you.
const unloadIframe = (iframe: HTMLIFrameElement) => {
if (iframe.src && iframe.src !== 'about:blank') {
iframe.dataset.savedSrc = iframe.src
iframe.src = 'about:blank'
}
}

const restoreIframe = (iframe: HTMLIFrameElement) => {
const saved = iframe.dataset.savedSrc
if (saved && iframe.src === 'about:blank') {
iframe.src = saved
delete iframe.dataset.savedSrc
}
}

// Embla cancels the click that follows a drag, so dragging across the overlay
// never accidentally unlocks a post.
Expand All @@ -237,20 +217,18 @@ const onSelect = (index: number) => {
activeIndex.value = index
unlockedIndex.value = null
if (!props.pauseOnLeave) return
const previous = iframeEls.get(previousIndex)
if (previous) unloadIframe(previous)
const current = iframeEls.get(index)
if (current) restoreIframe(current)
park(previousIndex)
restore(index)
}

useScrollAwayHandler(
rootEl,
() => {
if (props.onScrollAway !== 'pause') return
iframeEls.forEach(unloadIframe)
parkAll()
},
() => {
iframeEls.forEach(restoreIframe)
restoreAll()
},
)
</script>
Expand Down
65 changes: 16 additions & 49 deletions src/runtime/components/TikTokCarousel.vue
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,7 @@
</button>
<iframe
v-else
:ref="(el: Element | null) => bindIframe(el, index)"
:ref="(el: unknown) => bindIframe(el, index)"
class="weburz-tiktok-embed"
:src="buildEmbedUrl(video.url)"
:title="captionTitle(video) ?? `TikTok video ${index + 1}`"
Expand Down Expand Up @@ -148,6 +148,8 @@ import { computed, onMounted, ref } from 'vue'
import type { EmblaOptionsType, EmblaPluginType } from 'embla-carousel'
import type { SlidesPerView, TikTokCarouselMode, TikTokVideo } from '../types'
import { useEmbedMetadata } from '../composables/useEmbedMetadata'
import { useFacadeActivation } from '../composables/useFacadeActivation'
import { useFrameRegistry } from '../composables/useFrameRegistry'
import { useScrollAwayHandler } from '../composables/useScrollAwayHandler'

interface Props {
Expand Down Expand Up @@ -230,8 +232,9 @@ onMounted(() => {
const rootEl = ref<HTMLElement | null>(null)
const activeIndex = ref(0)
const activeVideo = computed(() => props.videos[activeIndex.value])
const iframeEls = new Map<number, HTMLIFrameElement>()
const boundIframes = new Set<number>()

const { activated, isActivated, activate, deactivate } = useFacadeActivation<number>()
const { bind: bindIframe, remove: removeIframe, park, restore, parkAll, restoreAll } = useFrameRegistry<number>()

const extractTikTokId = (url: string): string => {
const match = url.match(/\/video\/(\d+)/)
Expand All @@ -243,61 +246,25 @@ const buildEmbedUrl = (url: string): string => {
return `https://www.tiktok.com/embed/v2/${id}`
}

const bindIframe = (el: Element | null, index: number) => {
if (!el || !(el instanceof HTMLIFrameElement)) return
iframeEls.set(index, el)
if (boundIframes.has(index)) return
boundIframes.add(index)
}

// Facade activation: the thumbnail button swaps for the live iframe (which
// autoplays — TikTok's /embed/v2/ starts on load). Deactivating destroys the
// iframe, hard-stopping playback, and brings the thumbnail back.
const activatedIndexes = ref(new Set<number>())
const isActivated = (index: number) => activatedIndexes.value.has(index)

const activate = (index: number) => {
const next = new Set(activatedIndexes.value)
next.add(index)
activatedIndexes.value = next
}

const deactivate = (index: number) => {
if (!activatedIndexes.value.has(index)) return
const next = new Set(activatedIndexes.value)
next.delete(index)
activatedIndexes.value = next
iframeEls.delete(index)
boundIframes.delete(index)
}

const unloadIframe = (iframe: HTMLIFrameElement) => {
if (iframe.src && iframe.src !== 'about:blank') {
iframe.dataset.savedSrc = iframe.src
iframe.src = 'about:blank'
}
}

const restoreIframe = (iframe: HTMLIFrameElement) => {
const saved = iframe.dataset.savedSrc
if (saved && iframe.src === 'about:blank') {
iframe.src = saved
delete iframe.dataset.savedSrc
}
const deactivateFacade = (index: number) => {
if (!isActivated(index)) return
deactivate(index)
removeIframe(index)
}

const onSelect = (index: number) => {
const previousIndex = activeIndex.value
activeIndex.value = index
if (!props.pauseOnLeave) return
if (props.mode === 'facade') {
deactivate(previousIndex)
deactivateFacade(previousIndex)
return
}
const previous = iframeEls.get(previousIndex)
if (previous) unloadIframe(previous)
const current = iframeEls.get(index)
if (current) restoreIframe(current)
park(previousIndex)
restore(index)
}

useScrollAwayHandler(
Expand All @@ -307,14 +274,14 @@ useScrollAwayHandler(
if (props.mode === 'facade') {
// Destroy rather than about:blank-park: the facade is the natural
// stopped state, and it stays swipeable when the user scrolls back.
for (const index of [...activatedIndexes.value]) deactivate(index)
for (const index of [...activated.value]) deactivateFacade(index)
return
}
iframeEls.forEach(unloadIframe)
parkAll()
},
() => {
if (props.mode === 'facade') return
iframeEls.forEach(restoreIframe)
restoreAll()
},
)
</script>
Expand Down
57 changes: 22 additions & 35 deletions src/runtime/components/YouTubeCarousel.vue
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@
</button>
<iframe
v-else-if="mode === 'iframe-embed' || mode === 'facade'"
:ref="(el: Element | null) => bindIframe(el, video)"
:ref="(el: unknown) => bindIframe(el, video.id, (frame) => sendListening(frame, video.id))"
:src="buildEmbedUrl(video)"
:title="captionTitle(video) ?? `YouTube ${video.kind ?? 'video'} ${video.id}`"
loading="lazy"
Expand All @@ -116,7 +116,7 @@
/>
<div
v-else
:ref="(el: Element | null) => bindPlayer(el as HTMLElement | null, video)"
:ref="(el: unknown) => bindPlayer(el as HTMLElement | null, video)"
class="weburz-yt__player"
:data-video-id="video.id"
/>
Expand Down Expand Up @@ -152,6 +152,8 @@ import { computed, onMounted, ref } from 'vue'
import type { EmblaOptionsType, EmblaPluginType } from 'embla-carousel'
import type { SlidesPerView, YouTubeCarouselMode, YouTubeVideo } from '../types'
import { useEmbedMetadata } from '../composables/useEmbedMetadata'
import { useFacadeActivation } from '../composables/useFacadeActivation'
import { useFrameRegistry } from '../composables/useFrameRegistry'
import { useScrollAwayHandler } from '../composables/useScrollAwayHandler'
import { useYouTubePlayer } from '../composables/useYouTubePlayer'

Expand Down Expand Up @@ -239,22 +241,13 @@ const onThumbError = (video: YouTubeVideo) => {
// Activated facade slides have swapped their thumbnail for a live iframe.
// Deactivating destroys the iframe (hard-stops playback) and brings the
// facade back.
const activatedIds = ref(new Set<string>())
const isActivated = (id: string) => activatedIds.value.has(id)
const { isActivated, activate, deactivate } = useFacadeActivation<string>()
const { bind: bindIframe, get: getIframe, remove: removeIframe } = useFrameRegistry<string>()

const activate = (id: string) => {
const next = new Set(activatedIds.value)
next.add(id)
activatedIds.value = next
}

const deactivate = (id: string) => {
if (!activatedIds.value.has(id)) return
const next = new Set(activatedIds.value)
next.delete(id)
activatedIds.value = next
iframeEls.delete(id)
registeredIframes.delete(id)
const deactivateFacade = (id: string) => {
if (!isActivated(id)) return
deactivate(id)
removeIframe(id)
}

const { forYouTube } = useEmbedMetadata()
Expand Down Expand Up @@ -283,9 +276,7 @@ const rootEl = ref<HTMLElement | null>(null)
const activeIndex = ref(0)
const activeVideo = computed(() => props.videos[activeIndex.value])
const playerEls = new Map<string, HTMLElement>()
const iframeEls = new Map<string, HTMLIFrameElement>()
const registeredPlayers = new Set<string>()
const registeredIframes = new Set<string>()

const { register, pause, play, mute, unmute } = useYouTubePlayer()

Expand All @@ -303,25 +294,21 @@ const bindPlayer = (el: HTMLElement | null, video: YouTubeVideo) => {
}
}

const bindIframe = (el: Element | null, video: YouTubeVideo) => {
if (!el || !(el instanceof HTMLIFrameElement)) return
iframeEls.set(video.id, el)
if (registeredIframes.has(video.id)) return
registeredIframes.add(video.id)
// YouTube only reliably accepts postMessage commands once the parent has
// sent a "listening" handshake. Without this, mute/pause work intermittently.
const sendListening = () => {
// YouTube only reliably accepts postMessage commands once the parent has sent a
// "listening" handshake. Without this, mute/pause work intermittently.
const sendListening = (el: HTMLIFrameElement, id: string) => {
const handshake = () => {
el.contentWindow?.postMessage(
JSON.stringify({
event: 'listening',
id: `weburz-${video.id}`,
id: `weburz-${id}`,
channel: 'widget',
}),
'*',
)
}
el.addEventListener('load', sendListening, { once: true })
sendListening()
el.addEventListener('load', handshake, { once: true })
handshake()
}

const postIframeCommand = (iframe: HTMLIFrameElement, func: string) => {
Expand All @@ -338,7 +325,7 @@ const unmuteIframe = (iframe: HTMLIFrameElement) => postIframeCommand(iframe, 'u

const playByVideo = (video: YouTubeVideo) => {
if (props.mode !== 'player-api') {
const iframe = iframeEls.get(video.id)
const iframe = getIframe(video.id)
if (iframe) playIframe(iframe)
return
}
Expand All @@ -348,7 +335,7 @@ const playByVideo = (video: YouTubeVideo) => {

const pauseByVideo = (video: YouTubeVideo) => {
if (props.mode !== 'player-api') {
const iframe = iframeEls.get(video.id)
const iframe = getIframe(video.id)
if (iframe) pauseIframe(iframe)
return
}
Expand All @@ -358,7 +345,7 @@ const pauseByVideo = (video: YouTubeVideo) => {

const muteByVideo = (video: YouTubeVideo) => {
if (props.mode !== 'player-api') {
const iframe = iframeEls.get(video.id)
const iframe = getIframe(video.id)
if (iframe) muteIframe(iframe)
return
}
Expand All @@ -368,7 +355,7 @@ const muteByVideo = (video: YouTubeVideo) => {

const unmuteByVideo = (video: YouTubeVideo) => {
if (props.mode !== 'player-api') {
const iframe = iframeEls.get(video.id)
const iframe = getIframe(video.id)
if (iframe) unmuteIframe(iframe)
return
}
Expand All @@ -384,7 +371,7 @@ const onSelect = (index: number) => {
if (!previousVideo) return
// Facade slides revert to the thumbnail on leave: destroying the iframe is
// the only hard stop, and the facade is the natural "stopped" state.
if (props.mode === 'facade') deactivate(previousVideo.id)
if (props.mode === 'facade') deactivateFacade(previousVideo.id)
else pauseByVideo(previousVideo)
}

Expand Down
Loading