feat(app): video player, guide page, free films, PWA cache fix

- Add VideoPlayerOverlay component for free film playback
- Add GuidePage with interactive node setup walkthrough
- Add freeFilms data catalog with public domain films
- Enhance PlayerBar with video support and queue management
- Add video player store for overlay state management
- Refactor music search plugin (Jamendo integration cleanup)
- Add PWA cache version purge mechanism in main.ts
- Add PWA icon cache fix skill for Brave/Chrome
- Improve content grids: loading states, image fallbacks
- Enhance useArchy composable with node context
- Update useNostr with relay pool management
- Expand chat store with guide conversation support
- Add test fixtures for guide and node demo prompts

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-03-06 00:56:39 +00:00
co-authored by Claude Opus 4.6
parent c84c0fb424
commit b77c93607a
32 changed files with 3027 additions and 413 deletions
@@ -177,7 +177,7 @@ defineEmits<{
const chatStore = useChatStore()
const { sendMessage, stopGeneration, editAndResend, regenerateLastResponse, activeModel } = useAI()
const { updatePanelFromText, panelOpen, activeTab, availableTabs, setActiveTab, enterDesignSystemMode } = useContentPanel()
const { updatePanelFromText, panelOpen, panelFilms, panelTitle, activeTab, availableTabs, setActiveTab, enterDesignSystemMode } = useContentPanel()
import { useCodeContext } from '@/composables/useCodeContext'
import { useVisualViewport } from '@/composables/useVisualViewport'
const codeContext = useCodeContext()
@@ -364,6 +364,21 @@ async function handleSend(text: string, images: ImageAttachment[] = []) {
return
}
if (trimmed === '/freefilms') {
const { freeFilms } = await import('@/data/freeFilms')
panelFilms.value = freeFilms
panelTitle.value = 'Free Documentary Films'
panelOpen.value = true
availableTabs.value = ['film', 'prompt']
setActiveTab('film')
const convId = chatStore.activeConversationId
if (convId) {
chatStore.addMessage(convId, { role: 'user', content: '/freefilms' })
chatStore.addMessage(convId, { role: 'assistant', content: `Browse ${freeFilms.length} free documentary films from InDeeHub. Click any film to see details, and hit play to watch.` })
}
return
}
if (trimmed === '/code exit' || trimmed === '/exit') {
if (codeContext.isCodeMode.value) {
codeContext.exitCodeMode()
@@ -100,6 +100,7 @@ const BUILT_IN_COMMANDS: PaletteCommand[] = [
{ id: 'cmd-design', slash: '/design', title: 'Design System', preview: 'Open the design system viewer' },
{ id: 'cmd-search', slash: '/search ', title: 'Search', preview: 'Search your content library' },
{ id: 'cmd-seed', slash: '/seed', title: 'Seed', preview: 'Load seed conversations for all content types' },
{ id: 'cmd-freefilms', slash: '/freefilms', title: 'Free Films', preview: 'Browse free documentary films from InDeeHub' },
]
const props = defineProps<{
@@ -53,7 +53,8 @@
@click="$emit('selectBook', book)"
>
<div class="cover-card flex-1 min-h-0 relative">
<div class="aspect-[2/3] relative w-full overflow-hidden rounded-[10px]">
<div class="aspect-[2/3] relative w-full overflow-hidden rounded-[10px]" :class="!coverSrc(book) ? (isDark ? 'bg-white/[0.06]' : 'bg-black/[0.04]') : ''">
<div v-if="!coverSrc(book) && !failedCovers.has(book.id)" class="absolute inset-0 animate-shimmer" />
<img
v-if="coverSrc(book)"
:src="coverSrc(book)!"
@@ -63,7 +64,7 @@
@error="onCoverError(book)"
/>
<div
v-else
v-else-if="failedCovers.has(book.id)"
class="w-full h-full bg-cover bg-center"
:style="{ backgroundImage: `url(${fallbackFor(book)})` }"
/>
@@ -102,7 +103,7 @@
</template>
<script setup lang="ts">
import { ref, computed, reactive, onMounted, watch } from 'vue'
import { ref, computed, reactive, watch } from 'vue'
import type { Book } from '@aiui/core/types/content'
import { useTheme } from '@/composables/useTheme'
import { generateBookCoverFallback, fetchBookCover } from '@/composables/useImageFallback'
@@ -138,15 +139,20 @@ function onCoverError(book: Book) {
function fetchCoversFor(books: Book[]) {
for (const book of books) {
if (book.coverUrl || fetchedCovers.has(book.id)) continue
if (book.coverUrl || fetchedCovers.has(book.id) || failedCovers.value.has(book.id)) continue
fetchBookCover(book.title, book.author).then((url) => {
if (url) fetchedCovers.set(book.id, url)
}).catch(() => {})
if (url) {
fetchedCovers.set(book.id, url)
} else {
failedCovers.value = new Set([...failedCovers.value, book.id])
}
}).catch(() => {
failedCovers.value = new Set([...failedCovers.value, book.id])
})
}
}
onMounted(() => fetchCoversFor(props.books))
watch(() => props.books, (books) => fetchCoversFor(books), { immediate: false })
watch(() => props.books, (books) => fetchCoversFor(books), { immediate: true })
const topGenres = computed(() => {
const counts = new Map<string, number>()
@@ -24,6 +24,19 @@
</svg>
</button>
<button
v-if="playableSource"
class="absolute inset-0 flex items-center justify-center z-[5] group/play"
aria-label="Watch film"
@click="openVideo"
>
<span class="w-20 h-20 rounded-full flex items-center justify-center path-glass-icon group-hover/play:scale-110 transition-transform">
<svg class="w-10 h-10 text-white" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7L8 5z" />
</svg>
</span>
</button>
<div class="absolute bottom-0 left-0 right-0 p-4">
<h2 class="text-lg font-bold text-white">{{ film.title }}</h2>
<div class="flex items-center gap-2 mt-1 text-xs text-white/60">
@@ -108,14 +121,25 @@ import type { Film } from '@aiui/core/types/content'
import { useTheme } from '@/composables/useTheme'
import { useBannerFallback } from '@/composables/useBannerFallback'
import { fetchFilmImage } from '@/composables/useImageFallback'
import { useVideoPlayerStore } from '@/stores/videoPlayer'
const props = defineProps<{ film: Film }>()
defineEmits<{ back: [] }>()
const { isDark } = useTheme()
const videoStore = useVideoPlayerStore()
const isExternal = computed(() => props.film.id.startsWith('ext-'))
const playableSource = computed(() =>
props.film.sources.find(s => s.type === 'youtube' || s.url.includes('youtube.com'))
)
function openVideo() {
if (!playableSource.value) return
videoStore.open(playableSource.value.url, props.film.title, props.film.posterUrl || props.film.backdropUrl)
}
const { bannerSrc, fallbackGradient, onBannerError } = useBannerFallback({
primaryUrls: () => [props.film.backdropUrl, props.film.posterUrl],
apiFetch: () => fetchFilmImage(props.film.title, props.film.year),
@@ -53,7 +53,8 @@
@click="$emit('selectFilm', film)"
>
<div class="poster-card flex-1 min-h-0">
<div class="aspect-[2/3] relative w-full overflow-hidden rounded-[10px]">
<div class="aspect-[2/3] relative w-full overflow-hidden rounded-[10px]" :class="!coverSrc(film) ? (isDark ? 'bg-white/[0.06]' : 'bg-black/[0.04]') : ''">
<div v-if="!coverSrc(film) && !failedCovers.has(film.id)" class="absolute inset-0 animate-shimmer" />
<img
v-if="coverSrc(film)"
:src="coverSrc(film)!"
@@ -63,7 +64,7 @@
@error="onCoverError(film)"
/>
<div
v-else
v-else-if="failedCovers.has(film.id)"
class="w-full h-full bg-cover bg-center"
:style="{ backgroundImage: `url(${fallbackFor(film)})` }"
/>
@@ -103,7 +104,7 @@
</template>
<script setup lang="ts">
import { ref, computed, reactive, onMounted, watch } from 'vue'
import { ref, computed, reactive, watch } from 'vue'
import type { Film } from '@aiui/core/types/content'
import { useTheme } from '@/composables/useTheme'
import { handleImgError, fetchFilmImage, generatePosterFallback } from '@/composables/useImageFallback'
@@ -125,7 +126,7 @@ const fetchedCovers = reactive<Map<string, string>>(new Map())
function coverSrc(film: Film): string | null {
if (failedCovers.value.has(film.id)) return null
const url = film.posterUrl || fetchedCovers.get(film.id)
const url = film.posterUrl || film.backdropUrl || fetchedCovers.get(film.id)
return url || null
}
@@ -140,15 +141,20 @@ function onCoverError(film: Film) {
function fetchCoversFor(films: Film[]) {
for (const film of films) {
if (film.posterUrl || fetchedCovers.has(film.id)) continue
if (film.posterUrl || fetchedCovers.has(film.id) || failedCovers.value.has(film.id)) continue
fetchFilmImage(film.title, film.year).then((result) => {
if (result.posterUrl) fetchedCovers.set(film.id, result.posterUrl)
}).catch(() => {})
if (result.posterUrl) {
fetchedCovers.set(film.id, result.posterUrl)
} else {
failedCovers.value = new Set([...failedCovers.value, film.id])
}
}).catch(() => {
failedCovers.value = new Set([...failedCovers.value, film.id])
})
}
}
onMounted(() => fetchCoversFor(props.films))
watch(() => props.films, (films) => fetchCoversFor(films), { immediate: false })
watch(() => props.films, (films) => fetchCoversFor(films), { immediate: true })
const topGenres = computed(() => {
const counts = new Map<string, number>()
@@ -159,8 +159,21 @@
@click="selectedNoteId = note.id"
>
<div class="flex items-start gap-2.5">
<div class="w-8 h-8 rounded-full shrink-0 flex items-center justify-center text-xs font-bold bg-purple-500/20 text-purple-400">
{{ note.authorName?.charAt(0)?.toUpperCase() ?? '?' }}
<div class="w-8 h-8 rounded-full shrink-0 overflow-hidden">
<img
v-if="note.authorPicture && !failedAvatars.has(note.pubkey)"
:src="note.authorPicture"
:alt="note.authorName ?? 'profile'"
class="w-full h-full object-cover"
loading="lazy"
@error="failedAvatars.add(note.pubkey)"
/>
<div
v-else
class="w-full h-full flex items-center justify-center text-xs font-bold bg-purple-500/20 text-purple-400"
>
{{ note.authorName?.charAt(0)?.toUpperCase() ?? '?' }}
</div>
</div>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-1.5">
@@ -270,6 +283,7 @@ const subTabs = [
]
const selectedNoteId = ref<string | null>(null)
const failedAvatars = reactive(new Set<string>())
const search = ref('')
const activeKind = ref<number | null>(null)
const showCompose = ref(false)
@@ -53,7 +53,8 @@
@click="$emit('selectPodcast', podcast)"
>
<div class="cover-card flex-1 min-h-0 relative">
<div class="aspect-square relative w-full overflow-hidden rounded-[10px]">
<div class="aspect-square relative w-full overflow-hidden rounded-[10px]" :class="!coverSrc(podcast) ? (isDark ? 'bg-white/[0.06]' : 'bg-black/[0.04]') : ''">
<div v-if="!coverSrc(podcast) && !failedCovers.has(podcast.id)" class="absolute inset-0 animate-shimmer" />
<img
v-if="coverSrc(podcast)"
:src="coverSrc(podcast)!"
@@ -63,7 +64,7 @@
@error="onCoverError(podcast)"
/>
<div
v-else
v-else-if="failedCovers.has(podcast.id)"
class="w-full h-full bg-cover bg-center"
:style="{ backgroundImage: `url(${fallbackFor(podcast)})` }"
/>
@@ -100,7 +101,7 @@
</template>
<script setup lang="ts">
import { ref, computed, reactive, onMounted, watch } from 'vue'
import { ref, computed, reactive, watch } from 'vue'
import type { Podcast } from '@aiui/core/types/content'
import { useTheme } from '@/composables/useTheme'
import { generatePodcastCoverFallback, fetchPodcastCover } from '@/composables/useImageFallback'
@@ -127,15 +128,20 @@ function coverSrc(podcast: Podcast): string | null {
function fetchCoversFor(podcasts: Podcast[]) {
for (const podcast of podcasts) {
if (podcast.coverUrl || fetchedCovers.has(podcast.id)) continue
if (podcast.coverUrl || fetchedCovers.has(podcast.id) || failedCovers.value.has(podcast.id)) continue
fetchPodcastCover(podcast.title, podcast.host).then((url) => {
if (url) fetchedCovers.set(podcast.id, url)
}).catch(() => {})
if (url) {
fetchedCovers.set(podcast.id, url)
} else {
failedCovers.value = new Set([...failedCovers.value, podcast.id])
}
}).catch(() => {
failedCovers.value = new Set([...failedCovers.value, podcast.id])
})
}
}
onMounted(() => fetchCoversFor(props.podcasts))
watch(() => props.podcasts, (p) => fetchCoversFor(p), { immediate: false })
watch(() => props.podcasts, (p) => fetchCoversFor(p), { immediate: true })
function fallbackFor(podcast: Podcast): string {
return generatePodcastCoverFallback(podcast.title, podcast.host)
@@ -153,12 +153,7 @@ const q = computed(() =>
const listenLinks = computed(() => {
const links: { name: string; url: string; icon: string; desc?: string }[] = [
{ name: 'Internet Archive', url: `https://archive.org/search?query=${q.value}`, icon: '🏛️', desc: 'Free, open archive' },
{ name: 'Bandcamp', url: `https://bandcamp.com/search?q=${q.value}`, icon: '📦', desc: 'Artist-first' },
{ name: 'SoundCloud', url: `https://soundcloud.com/search?q=${q.value}`, icon: '☁️', desc: 'Indie & remixes' },
{ name: 'Wavlake', url: `https://wavlake.com/`, icon: '⚡', desc: 'Lightning, indie discovery' },
{ name: 'Odysee', url: `https://odysee.com/$/search?q=${q.value}`, icon: '🔗', desc: 'Decentralized' },
{ name: 'Jamendo', url: `https://www.jamendo.com/search?q=${q.value}`, icon: '🎵', desc: 'Royalty-free' },
{ name: 'Wavlake', url: `https://wavlake.com/search?q=${q.value}`, icon: '', desc: 'Lightning-powered music' },
]
return links
})
@@ -50,14 +50,15 @@
:key="song.id"
class="group flex flex-col items-stretch text-left w-full path-glass-bubble rounded-2xl overflow-hidden transition-all duration-200 hover:brightness-105"
:aria-label="`${song.title} by ${song.artist}`"
@click="$emit('selectSong', song)"
@click="emit('selectSong', song)"
>
<div class="cover-card flex-1 min-h-0 relative flex items-center justify-center">
<div class="aspect-square relative w-full overflow-hidden rounded-[10px]">
<div class="aspect-square relative w-full overflow-hidden rounded-[10px]" :class="!coverSrc(song) ? (isDark ? 'bg-white/[0.06]' : 'bg-black/[0.04]') : ''">
<div v-if="!coverSrc(song) && !failedCovers.has(song.id)" class="absolute inset-0 animate-shimmer" />
<button
class="absolute inset-0 bottom-10 flex items-center justify-center z-10 backdrop-blur-sm bg-black/30 opacity-0 group-hover:opacity-100 transition-all duration-200"
class="absolute inset-0 flex items-center justify-center z-10 backdrop-blur-sm bg-black/30 opacity-0 group-hover:opacity-100 transition-all duration-200"
aria-label="Play"
@click.stop="onPlayClick(song)"
@click.stop="play(song); emit('selectSong', song)"
>
<span class="w-16 h-16 rounded-full flex items-center justify-center path-glass-icon">
<svg class="w-8 h-8 text-white" fill="currentColor" viewBox="0 0 24 24">
@@ -74,7 +75,7 @@
@error="onCoverError(song)"
/>
<div
v-else
v-else-if="failedCovers.has(song.id)"
class="w-full h-full bg-cover bg-center"
:style="{ backgroundImage: `url(${fallbackFor(song)})` }"
/>
@@ -111,7 +112,7 @@
</template>
<script setup lang="ts">
import { ref, computed, reactive, onMounted, watch } from 'vue'
import { ref, computed, reactive, watch } from 'vue'
import type { Song } from '@aiui/core/types/content'
import { useTheme } from '@/composables/useTheme'
import { usePlayer } from '@/composables/usePlayer'
@@ -124,7 +125,7 @@ const props = withDefaults(defineProps<{
title: 'Recommended Songs',
})
defineEmits<{ selectSong: [song: Song] }>()
const emit = defineEmits<{ selectSong: [song: Song] }>()
const { isDark } = useTheme()
const { play } = usePlayer()
@@ -148,21 +149,22 @@ function onCoverError(song: Song) {
failedCovers.value = new Set(failedCovers.value)
}
function onPlayClick(song: Song) {
play(song)
}
function fetchCoversFor(songs: Song[]) {
for (const song of songs) {
if (song.coverUrl || fetchedCovers.has(song.id)) continue
if (song.coverUrl || fetchedCovers.has(song.id) || failedCovers.value.has(song.id)) continue
fetchMusicCover(song.title, song.artist, song.album).then((url) => {
if (url) fetchedCovers.set(song.id, url)
}).catch(() => {})
if (url) {
fetchedCovers.set(song.id, url)
} else {
failedCovers.value = new Set([...failedCovers.value, song.id])
}
}).catch(() => {
failedCovers.value = new Set([...failedCovers.value, song.id])
})
}
}
onMounted(() => fetchCoversFor(props.songs))
watch(() => props.songs, (songs) => fetchCoversFor(songs), { immediate: false })
watch(() => props.songs, (songs) => fetchCoversFor(songs), { immediate: true })
const topGenres = computed(() => {
const counts = new Map<string, number>()
@@ -53,7 +53,8 @@
@click="$emit('selectSeries', s)"
>
<div class="cover-card flex-1 min-h-0 relative">
<div class="aspect-[2/3] relative w-full overflow-hidden rounded-[10px]">
<div class="aspect-[2/3] relative w-full overflow-hidden rounded-[10px]" :class="!coverSrc(s) ? (isDark ? 'bg-white/[0.06]' : 'bg-black/[0.04]') : ''">
<div v-if="!coverSrc(s) && !failedCovers.has(s.id)" class="absolute inset-0 animate-shimmer" />
<img
v-if="coverSrc(s)"
:src="coverSrc(s)!"
@@ -63,7 +64,7 @@
@error="onCoverError(s)"
/>
<div
v-else
v-else-if="failedCovers.has(s.id)"
class="w-full h-full bg-cover bg-center"
:style="{ backgroundImage: `url(${fallbackFor(s)})` }"
/>
@@ -114,7 +115,7 @@
</template>
<script setup lang="ts">
import { ref, computed, reactive, onMounted, watch } from 'vue'
import { ref, computed, reactive, watch } from 'vue'
import type { TVSeries } from '@aiui/core/types/content'
import { useTheme } from '@/composables/useTheme'
import { generateTVSeriesFallback, fetchTVImage } from '@/composables/useImageFallback'
@@ -136,7 +137,7 @@ const fetchedCovers = reactive<Map<string, string>>(new Map())
function coverSrc(s: TVSeries): string | null {
if (failedCovers.value.has(s.id)) return null
return s.posterUrl || fetchedCovers.get(s.id) || null
return s.posterUrl || s.backdropUrl || fetchedCovers.get(s.id) || null
}
function fallbackFor(s: TVSeries): string {
@@ -157,15 +158,20 @@ function yearDisplay(s: TVSeries): string {
function fetchCoversFor(list: TVSeries[]) {
for (const s of list) {
if (s.posterUrl || fetchedCovers.has(s.id)) continue
if (s.posterUrl || fetchedCovers.has(s.id) || failedCovers.value.has(s.id)) continue
fetchTVImage(s.title, s.year).then((result) => {
if (result.posterUrl) fetchedCovers.set(s.id, result.posterUrl)
}).catch(() => {})
if (result.posterUrl) {
fetchedCovers.set(s.id, result.posterUrl)
} else {
failedCovers.value = new Set([...failedCovers.value, s.id])
}
}).catch(() => {
failedCovers.value = new Set([...failedCovers.value, s.id])
})
}
}
onMounted(() => fetchCoversFor(props.series))
watch(() => props.series, (list) => fetchCoversFor(list), { immediate: false })
watch(() => props.series, (list) => fetchCoversFor(list), { immediate: true })
const topGenres = computed(() => {
const counts = new Map<string, number>()
+140 -62
View File
@@ -6,7 +6,7 @@
props.variant === 'fixed'
? 'fixed bottom-0 left-0 right-0 z-[999]'
: 'w-full shrink-0',
'flex items-center gap-4 px-4 py-3 path-glass-card rounded-none border-t-0 border-x-0 shadow-2xl'
'path-glass-card !rounded-none'
]"
>
<!-- Plyr container: YouTube requires min 200x200px. Kept off-screen but sized. -->
@@ -16,99 +16,176 @@
aria-hidden="true"
/>
<div class="flex items-center gap-3 min-w-0 flex-1 max-w-[280px]">
<!-- Compact layout: mini-player (mobile) -->
<div v-if="props.compact" class="flex flex-col">
<!-- Scrubber bar (full width, thin) -->
<div
class="w-12 h-12 rounded-lg overflow-hidden shrink-0 flex items-center justify-center path-glass-icon"
class="w-full h-1 cursor-pointer bg-white/10"
@click="onScrubberClick"
>
<img
v-if="coverUrl"
:src="coverUrl"
:alt="currentSong!.title"
class="w-full h-full object-cover"
<div
class="h-full bg-accent transition-all duration-150"
:style="{ width: `${progress}%` }"
/>
<span v-else class="text-lg">🎵</span>
</div>
<div class="min-w-0">
<p class="text-sm font-semibold truncate text-white/90">
{{ currentSong!.title }}
</p>
<p class="text-xs truncate text-white/50">
{{ currentSong!.artist }}
</p>
</div>
</div>
<div class="flex flex-col gap-1 flex-1 max-w-xl mx-4">
<div class="flex items-center gap-2">
<!-- Previous button -->
<!-- Cover + info + controls -->
<div class="flex items-center gap-3 px-3 py-2">
<div class="w-10 h-10 rounded-lg overflow-hidden shrink-0 flex items-center justify-center path-glass-icon">
<img
v-if="coverUrl"
:src="coverUrl"
:alt="currentSong!.title"
class="w-full h-full object-cover"
/>
<span v-else class="text-base">🎵</span>
</div>
<div class="min-w-0 flex-1">
<p class="text-sm font-semibold truncate text-white/90">{{ currentSong!.title }}</p>
<p class="text-xs truncate text-white/50">{{ currentSong!.artist }}</p>
</div>
<button
class="min-w-[44px] min-h-[44px] rounded-full flex items-center justify-center shrink-0 transition-all hover:scale-105 active:scale-95"
:class="hasPrevious ? 'text-white/70 hover:text-white/90' : 'text-white/20'"
class="w-11 h-11 rounded-full flex items-center justify-center shrink-0 active:scale-95"
:class="hasPrevious ? 'text-white/70' : 'text-white/20'"
:disabled="!hasPrevious"
aria-label="Previous track"
@click="playPrevious"
>
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
<path d="M6 6h2v12H6V6zm3.5 6l8.5 6V6l-8.5 6z" />
</svg>
</button>
<button
class="w-12 h-12 rounded-full flex items-center justify-center glass-button shrink-0 transition-all hover:scale-105 active:scale-95"
class="w-12 h-12 rounded-full flex items-center justify-center glass-button shrink-0 active:scale-95"
aria-label="Play or pause"
@click="toggle"
>
<svg v-if="isLoading" class="w-5 h-5 animate-spin text-white/90" fill="none" viewBox="0 0 24 24">
<svg v-if="isLoading" class="w-6 h-6 animate-spin text-white/90" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
<svg v-else-if="isPlaying" class="w-5 h-5 text-white/90" fill="currentColor" viewBox="0 0 24 24">
<svg v-else-if="isPlaying" class="w-6 h-6 text-white/90" fill="currentColor" viewBox="0 0 24 24">
<path d="M6 4h4v16H6V4zm8 0h4v16h-4V4z" />
</svg>
<svg v-else class="w-5 h-5 text-white/90" fill="currentColor" viewBox="0 0 24 24">
<svg v-else class="w-6 h-6 text-white/90" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7L8 5z" />
</svg>
</button>
<!-- Next button -->
<button
class="min-w-[44px] min-h-[44px] rounded-full flex items-center justify-center shrink-0 transition-all hover:scale-105 active:scale-95"
:class="hasNext ? 'text-white/70 hover:text-white/90' : 'text-white/20'"
class="w-11 h-11 rounded-full flex items-center justify-center shrink-0 active:scale-95"
:class="hasNext ? 'text-white/70' : 'text-white/20'"
:disabled="!hasNext"
aria-label="Next track"
@click="playNext"
>
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
<path d="M6 18l8.5-6L6 6v12zm10-12v12h2V6h-2z" />
</svg>
</button>
<span class="text-xs font-mono tabular-nums text-white/40">
{{ formatTime(currentTime) }}
</span>
<div
class="flex-1 h-1.5 rounded-full cursor-pointer group bg-white/15"
@click="onScrubberClick"
<button
class="w-9 h-9 rounded-lg flex items-center justify-center shrink-0 active:scale-95 text-white/40"
aria-label="Close player"
@click="clear"
>
<div
class="h-full rounded-full transition-all duration-150 group-hover:h-2 bg-accent"
:style="{ width: `${progress}%` }"
/>
</div>
<span class="text-xs font-mono tabular-nums text-white/40">
{{ formatTime(duration) }}
</span>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<p v-if="error" class="text-xs text-red-400">{{ error }}</p>
<p v-if="error" class="text-xs text-red-400 px-3 pb-2">{{ error }}</p>
</div>
<span
v-if="queue.length > 1"
class="text-xs font-mono tabular-nums shrink-0 text-white/30"
>{{ queue.length }} songs</span>
<button
class="touch-target rounded-xl path-glass-button path-glass-button-sm shrink-0 transition-all hover:scale-105"
@click="clear"
title="Close player"
>
<svg class="w-4 h-4 text-white/70" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
<!-- Desktop layout: full controls with scrubber -->
<div v-else class="flex items-center gap-4 px-4 py-3">
<div class="flex items-center gap-3 min-w-0 flex-1 max-w-[280px]">
<div class="w-12 h-12 rounded-lg overflow-hidden shrink-0 flex items-center justify-center path-glass-icon">
<img
v-if="coverUrl"
:src="coverUrl"
:alt="currentSong!.title"
class="w-full h-full object-cover"
/>
<span v-else class="text-lg">🎵</span>
</div>
<div class="min-w-0">
<p class="text-sm font-semibold truncate text-white/90">{{ currentSong!.title }}</p>
<p class="text-xs truncate text-white/50">{{ currentSong!.artist }}</p>
</div>
</div>
<div class="flex flex-col gap-1 flex-1 max-w-xl mx-4">
<div class="flex items-center gap-2">
<button
class="min-w-[44px] min-h-[44px] rounded-full flex items-center justify-center shrink-0 transition-all hover:scale-105 active:scale-95"
:class="hasPrevious ? 'text-white/70 hover:text-white/90' : 'text-white/20'"
:disabled="!hasPrevious"
aria-label="Previous track"
@click="playPrevious"
>
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
<path d="M6 6h2v12H6V6zm3.5 6l8.5 6V6l-8.5 6z" />
</svg>
</button>
<button
class="w-12 h-12 rounded-full flex items-center justify-center glass-button shrink-0 transition-all hover:scale-105 active:scale-95"
aria-label="Play or pause"
@click="toggle"
>
<svg v-if="isLoading" class="w-6 h-6 animate-spin text-white/90" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
<svg v-else-if="isPlaying" class="w-6 h-6 text-white/90" fill="currentColor" viewBox="0 0 24 24">
<path d="M6 4h4v16H6V4zm8 0h4v16h-4V4z" />
</svg>
<svg v-else class="w-6 h-6 text-white/90" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7L8 5z" />
</svg>
</button>
<button
class="min-w-[44px] min-h-[44px] rounded-full flex items-center justify-center shrink-0 transition-all hover:scale-105 active:scale-95"
:class="hasNext ? 'text-white/70 hover:text-white/90' : 'text-white/20'"
:disabled="!hasNext"
aria-label="Next track"
@click="playNext"
>
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
<path d="M6 18l8.5-6L6 6v12zm10-12v12h2V6h-2z" />
</svg>
</button>
<span class="text-xs font-mono tabular-nums text-white/40">
{{ formatTime(currentTime) }}
</span>
<div
class="flex-1 h-1.5 rounded-full cursor-pointer group bg-white/15"
@click="onScrubberClick"
>
<div
class="h-full rounded-full transition-all duration-150 group-hover:h-2 bg-accent"
:style="{ width: `${progress}%` }"
/>
</div>
<span class="text-xs font-mono tabular-nums text-white/40">
{{ formatTime(duration) }}
</span>
</div>
<p v-if="error" class="text-xs text-red-400">{{ error }}</p>
</div>
<span
v-if="queue.length > 1"
class="text-xs font-mono tabular-nums shrink-0 text-white/30"
>{{ queue.length }} songs</span>
<button
class="touch-target rounded-xl path-glass-button path-glass-button-sm shrink-0 transition-all hover:scale-105"
aria-label="Close player"
@click="clear"
title="Close player"
>
<svg class="w-4 h-4 text-white/70" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
</Transition>
</template>
@@ -120,7 +197,8 @@ import { fetchMusicCover } from '@/composables/useImageFallback'
const props = withDefaults(defineProps<{
variant?: 'fixed' | 'inline'
}>(), { variant: 'fixed' })
compact?: boolean
}>(), { variant: 'fixed', compact: false })
const {
currentSong,
@@ -0,0 +1,516 @@
<template>
<Teleport to="body">
<Transition name="video-player">
<div
v-if="store.isOpen"
ref="containerRef"
class="fixed inset-0 z-[2500] flex flex-col bg-black"
:class="controlsVisible ? '' : 'cursor-none'"
@mousemove="showControls"
@touchstart.passive="showControls"
@keydown="onKeydown"
@click="onContainerClick"
tabindex="0"
>
<!-- Video area -->
<div class="flex-1 min-h-0 relative flex items-center justify-center">
<div
ref="playerRef"
class="w-full h-full"
/>
<!-- Big center play/pause indicator (flashes on toggle) -->
<Transition name="center-icon">
<div
v-if="showCenterIcon"
class="absolute inset-0 flex items-center justify-center pointer-events-none"
>
<div class="w-20 h-20 rounded-full flex items-center justify-center bg-black/40 backdrop-blur-sm">
<svg v-if="isPlaying" class="w-10 h-10 text-white" fill="currentColor" viewBox="0 0 24 24">
<path d="M6 4h4v16H6V4zm8 0h4v16h-4V4z" />
</svg>
<svg v-else class="w-10 h-10 text-white" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7L8 5z" />
</svg>
</div>
</div>
</Transition>
<!-- Loading spinner -->
<div v-if="isBuffering" class="absolute inset-0 flex items-center justify-center pointer-events-none">
<svg class="w-12 h-12 animate-spin text-white/60" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
</div>
</div>
<!-- Controls overlay bottom bar -->
<div
class="absolute bottom-0 left-0 right-0 transition-all duration-300 z-20"
:class="controlsVisible ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-4'"
>
<!-- Gradient fade above controls -->
<div class="h-24 bg-gradient-to-t from-black/80 to-transparent pointer-events-none" />
<div class="path-glass-card !rounded-none px-4 py-3 space-y-2">
<!-- Scrubber -->
<div
ref="scrubberRef"
class="group w-full h-1.5 rounded-full cursor-pointer bg-white/15 transition-all hover:h-2.5"
@click="onScrubberClick"
@mousedown="onScrubberDragStart"
>
<div
class="h-full bg-accent rounded-full transition-[width] duration-75 relative"
:style="{ width: `${progress}%` }"
>
<div class="absolute right-0 top-1/2 -translate-y-1/2 w-3 h-3 rounded-full bg-white shadow-md opacity-0 group-hover:opacity-100 transition-opacity" />
</div>
</div>
<!-- Controls row -->
<div class="flex items-center gap-3">
<!-- Title -->
<div class="min-w-0 flex-1 max-w-[280px]">
<p class="text-sm font-semibold truncate text-white/90">{{ store.title }}</p>
<p class="text-xs truncate text-white/40">Free Documentary</p>
</div>
<!-- Center controls -->
<div class="flex items-center gap-2 flex-1 justify-center">
<!-- Rewind 10s -->
<button
class="min-w-[44px] min-h-[44px] rounded-full flex items-center justify-center text-white/70 hover:text-white/90 transition-all hover:scale-105 active:scale-95"
aria-label="Rewind 10 seconds"
@click.stop="seek(-10)"
>
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
<path d="M12.5 8c-2.65 0-5.05.99-6.9 2.6L2 7v9h9l-3.62-3.62c1.39-1.16 3.16-1.88 5.12-1.88 3.54 0 6.55 2.31 7.6 5.5l2.37-.78C21.08 11.03 17.15 8 12.5 8z" />
</svg>
</button>
<!-- Play/Pause -->
<button
class="w-12 h-12 rounded-full flex items-center justify-center glass-button shrink-0 transition-all hover:scale-105 active:scale-95"
aria-label="Play or pause"
@click.stop="toggle"
>
<svg v-if="isPlaying" class="w-6 h-6 text-white/90" fill="currentColor" viewBox="0 0 24 24">
<path d="M6 4h4v16H6V4zm8 0h4v16h-4V4z" />
</svg>
<svg v-else class="w-6 h-6 text-white/90" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7L8 5z" />
</svg>
</button>
<!-- Forward 10s -->
<button
class="min-w-[44px] min-h-[44px] rounded-full flex items-center justify-center text-white/70 hover:text-white/90 transition-all hover:scale-105 active:scale-95"
aria-label="Forward 10 seconds"
@click.stop="seek(10)"
>
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
<path d="M18 13c0 3.31-2.69 6-6 6s-6-2.69-6-6 2.69-6 6-6v4l5-5-5-5v4c-4.42 0-8 3.58-8 8s3.58 8 8 8 8-3.58 8-8h-2z" />
</svg>
</button>
<!-- Time -->
<span class="text-xs font-mono tabular-nums text-white/40 ml-1">
{{ formatTime(currentTime) }} / {{ formatTime(duration) }}
</span>
</div>
<!-- Right controls -->
<div class="flex items-center gap-1 shrink-0">
<!-- Fullscreen -->
<button
class="min-w-[44px] min-h-[44px] rounded-full flex items-center justify-center text-white/60 hover:text-white/90 transition-colors"
aria-label="Toggle fullscreen"
@click.stop="toggleFullscreen"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 8V4m0 0h4M4 4l5 5m11-1V4m0 0h-4m4 0l-5 5M4 16v4m0 0h4m-4 0l5-5m11 5l-5-5m5 5v-4m0 4h-4" />
</svg>
</button>
<!-- Close -->
<button
class="min-w-[44px] min-h-[44px] rounded-full flex items-center justify-center text-white/60 hover:text-white/90 transition-colors"
aria-label="Close video player"
@click.stop="closePlayer"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
</div>
</div>
<!-- Top bar close button (visible with controls) -->
<div
class="absolute top-0 left-0 right-0 transition-all duration-300 z-20"
:class="controlsVisible ? 'opacity-100 translate-y-0' : 'opacity-0 -translate-y-4'"
>
<div class="h-16 bg-gradient-to-b from-black/60 to-transparent flex items-start justify-end px-4 pt-3">
<button
class="min-w-[44px] min-h-[44px] rounded-full flex items-center justify-center text-white/70 hover:text-white/90 hover:bg-white/10 transition-colors"
aria-label="Close"
@click.stop="closePlayer"
>
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<script setup lang="ts">
import { ref, watch, onMounted, onBeforeUnmount, nextTick } from 'vue'
import { useVideoPlayerStore } from '@/stores/videoPlayer'
const store = useVideoPlayerStore()
const containerRef = ref<HTMLElement | null>(null)
const playerRef = ref<HTMLElement | null>(null)
const scrubberRef = ref<HTMLElement | null>(null)
const isPlaying = ref(false)
const isBuffering = ref(false)
const currentTime = ref(0)
const duration = ref(0)
const progress = ref(0)
const controlsVisible = ref(true)
const showCenterIcon = ref(false)
let hideTimer: ReturnType<typeof setTimeout> | null = null
let rafId: number | null = null
let ytPlayer: any = null
let centerIconTimer: ReturnType<typeof setTimeout> | null = null
// YouTube IFrame API
let ytApiReady = false
const ytApiCallbacks: (() => void)[] = []
function loadYouTubeApi(): Promise<void> {
if (ytApiReady) return Promise.resolve()
return new Promise((resolve) => {
if ((window as any).YT?.Player) {
ytApiReady = true
resolve()
return
}
ytApiCallbacks.push(resolve)
if (!document.getElementById('yt-iframe-api')) {
const tag = document.createElement('script')
tag.id = 'yt-iframe-api'
tag.src = 'https://www.youtube.com/iframe_api'
document.head.appendChild(tag)
;(window as any).onYouTubeIframeAPIReady = () => {
ytApiReady = true
ytApiCallbacks.forEach(cb => cb())
ytApiCallbacks.length = 0
}
}
})
}
function extractYouTubeId(url: string): string | null {
// Handle embed URLs: youtube.com/embed/VIDEO_ID
const embedMatch = url.match(/\/embed\/([a-zA-Z0-9_-]{11})/)
if (embedMatch) return embedMatch[1]
// Handle watch URLs: youtube.com/watch?v=VIDEO_ID
const watchMatch = url.match(/[?&]v=([a-zA-Z0-9_-]{11})/)
if (watchMatch) return watchMatch[1]
// Handle youtu.be/VIDEO_ID
const shortMatch = url.match(/youtu\.be\/([a-zA-Z0-9_-]{11})/)
if (shortMatch) return shortMatch[1]
return null
}
async function initPlayer(url: string) {
const videoId = extractYouTubeId(url)
if (!videoId || !playerRef.value) return
isBuffering.value = true
await loadYouTubeApi()
const YT = (window as any).YT
// Create a div target inside playerRef
const target = document.createElement('div')
target.id = 'yt-video-player'
playerRef.value.innerHTML = ''
playerRef.value.appendChild(target)
ytPlayer = new YT.Player('yt-video-player', {
videoId,
width: '100%',
height: '100%',
playerVars: {
autoplay: 1,
controls: 0,
modestbranding: 1,
rel: 0,
showinfo: 0,
iv_load_policy: 3,
fs: 0,
playsinline: 1,
},
events: {
onReady: () => {
isBuffering.value = false
isPlaying.value = true
startPolling()
showControls()
},
onStateChange: (e: any) => {
const state = e.data
// -1: unstarted, 0: ended, 1: playing, 2: paused, 3: buffering, 5: cued
isPlaying.value = state === 1
isBuffering.value = state === 3
if (state === 0) {
// Video ended
isPlaying.value = false
controlsVisible.value = true
}
},
},
})
}
function startPolling() {
if (rafId) cancelAnimationFrame(rafId)
function poll() {
if (ytPlayer?.getCurrentTime && ytPlayer?.getDuration) {
currentTime.value = ytPlayer.getCurrentTime() ?? 0
duration.value = ytPlayer.getDuration() ?? 0
progress.value = duration.value > 0 ? (currentTime.value / duration.value) * 100 : 0
}
rafId = requestAnimationFrame(poll)
}
poll()
}
function stopPolling() {
if (rafId) {
cancelAnimationFrame(rafId)
rafId = null
}
}
function destroyPlayer() {
stopPolling()
if (ytPlayer?.destroy) {
try { ytPlayer.destroy() } catch {}
}
ytPlayer = null
if (playerRef.value) playerRef.value.innerHTML = ''
isPlaying.value = false
isBuffering.value = false
currentTime.value = 0
duration.value = 0
progress.value = 0
}
// Controls
function toggle() {
if (!ytPlayer) return
if (isPlaying.value) {
ytPlayer.pauseVideo()
} else {
ytPlayer.playVideo()
}
flashCenterIcon()
}
function seek(seconds: number) {
if (!ytPlayer?.seekTo) return
const target = Math.max(0, Math.min(duration.value, currentTime.value + seconds))
ytPlayer.seekTo(target, true)
showControls()
}
function seekToPercent(percent: number) {
if (!ytPlayer?.seekTo || duration.value <= 0) return
const target = (percent / 100) * duration.value
ytPlayer.seekTo(target, true)
}
function onScrubberClick(e: MouseEvent) {
if (!scrubberRef.value) return
const rect = scrubberRef.value.getBoundingClientRect()
const percent = Math.max(0, Math.min(100, ((e.clientX - rect.left) / rect.width) * 100))
seekToPercent(percent)
}
let isDragging = false
function onScrubberDragStart(e: MouseEvent) {
isDragging = true
onScrubberClick(e)
const onMove = (ev: MouseEvent) => { if (isDragging) onScrubberClick(ev) }
const onUp = () => {
isDragging = false
window.removeEventListener('mousemove', onMove)
window.removeEventListener('mouseup', onUp)
}
window.addEventListener('mousemove', onMove)
window.addEventListener('mouseup', onUp)
}
function toggleFullscreen() {
if (!containerRef.value) return
if (document.fullscreenElement) {
document.exitFullscreen()
} else {
containerRef.value.requestFullscreen()
}
}
function closePlayer() {
destroyPlayer()
store.close()
}
function flashCenterIcon() {
showCenterIcon.value = true
if (centerIconTimer) clearTimeout(centerIconTimer)
centerIconTimer = setTimeout(() => {
showCenterIcon.value = false
}, 600)
}
function onContainerClick(e: MouseEvent) {
// Only toggle on direct click on video area (not controls)
const target = e.target as HTMLElement
if (target === containerRef.value || target.closest('.flex-1.min-h-0')) {
toggle()
}
}
// Auto-hide controls
function showControls() {
controlsVisible.value = true
resetHideTimer()
}
function resetHideTimer() {
if (hideTimer) clearTimeout(hideTimer)
hideTimer = setTimeout(() => {
if (isPlaying.value) controlsVisible.value = false
}, 3000)
}
// Keyboard shortcuts
function onKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') {
e.preventDefault()
e.stopPropagation()
closePlayer()
return
}
if (e.key === ' ' || e.key === 'k') {
e.preventDefault()
toggle()
showControls()
return
}
if (e.key === 'ArrowLeft') {
e.preventDefault()
seek(-10)
return
}
if (e.key === 'ArrowRight') {
e.preventDefault()
seek(10)
return
}
if (e.key === 'f') {
e.preventDefault()
toggleFullscreen()
return
}
}
function formatTime(seconds: number): string {
const s = Math.floor(seconds)
const h = Math.floor(s / 3600)
const m = Math.floor((s % 3600) / 60)
const sec = s % 60
if (h > 0) return `${h}:${String(m).padStart(2, '0')}:${String(sec).padStart(2, '0')}`
return `${m}:${String(sec).padStart(2, '0')}`
}
// Global escape handler (captures even when focus is elsewhere)
function onGlobalKeydown(e: KeyboardEvent) {
if (e.key === 'Escape' && store.isOpen) {
e.preventDefault()
e.stopPropagation()
closePlayer()
}
}
// Watch store open/close
watch(() => store.isOpen, async (open) => {
if (open) {
await nextTick()
containerRef.value?.focus()
initPlayer(store.videoUrl)
showControls()
} else {
destroyPlayer()
}
})
onMounted(() => {
window.addEventListener('keydown', onGlobalKeydown, true)
})
onBeforeUnmount(() => {
destroyPlayer()
window.removeEventListener('keydown', onGlobalKeydown, true)
if (hideTimer) clearTimeout(hideTimer)
if (centerIconTimer) clearTimeout(centerIconTimer)
})
</script>
<style scoped>
.video-player-enter-active,
.video-player-leave-active {
transition: opacity 0.25s ease;
}
.video-player-enter-from,
.video-player-leave-to {
opacity: 0;
}
.center-icon-enter-active {
transition: opacity 0.15s ease, transform 0.15s ease;
}
.center-icon-leave-active {
transition: opacity 0.4s ease, transform 0.4s ease;
}
.center-icon-enter-from {
opacity: 0;
transform: scale(0.8);
}
.center-icon-leave-to {
opacity: 0;
transform: scale(1.3);
}
/* Make YouTube iframe fill container */
:deep(iframe) {
width: 100% !important;
height: 100% !important;
border: 0;
pointer-events: none;
}
</style>