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:
co-authored by
Claude Opus 4.6
parent
c84c0fb424
commit
b77c93607a
@@ -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>()
|
||||
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user