fix: uniform podcast player for all content types, hidden iframe for external audio
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 28s
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 28s
This commit is contained in:
parent
d9e69aa76c
commit
96eff9433c
@ -1,82 +1,32 @@
|
|||||||
import { useState } from 'react'
|
import { Play, Pause, Volume2, VolumeX, SkipBack, SkipForward } from 'lucide-react'
|
||||||
import { Play, Pause, Volume2, VolumeX, SkipBack, SkipForward, Maximize2, Minimize2 } from 'lucide-react'
|
|
||||||
import { usePlayerStore } from '@/stores/player'
|
import { usePlayerStore } from '@/stores/player'
|
||||||
import { formatDuration } from '@/lib/utils'
|
import { formatDuration } from '@/lib/utils'
|
||||||
import { getEmbedInfo } from '@/lib/embed'
|
|
||||||
import { Avatar } from '@/components/ui/Avatar'
|
import { Avatar } from '@/components/ui/Avatar'
|
||||||
|
|
||||||
export function PlayerBar() {
|
export function PlayerBar() {
|
||||||
const { current, isPlaying, isExternal, progress, duration, volume, toggle, seek, setVolume } = usePlayerStore()
|
const { current, isPlaying, isExternal, progress, duration, volume, toggle, seek, setVolume } = usePlayerStore()
|
||||||
const [expanded, setExpanded] = useState(false)
|
|
||||||
|
|
||||||
if (!current) return null
|
if (!current) return null
|
||||||
|
|
||||||
const embedInfo = isExternal ? getEmbedInfo(current.audio_url) : null
|
|
||||||
|
|
||||||
// External content: show expandable embed player
|
|
||||||
if (isExternal && embedInfo && isPlaying) {
|
|
||||||
return (
|
|
||||||
<div className={`fixed bottom-0 left-0 right-0 z-50 glass border-t border-border/60 shadow-[0_-4px_30px_rgba(30,27,51,0.08)] transition-all duration-300 ${expanded ? 'h-[70vh]' : 'h-[4.75rem]'}`}>
|
|
||||||
{expanded && (
|
|
||||||
<div className="w-full h-[calc(100%-4.75rem)] bg-black/95">
|
|
||||||
<iframe
|
|
||||||
src={embedInfo.embedUrl}
|
|
||||||
className="w-full h-full"
|
|
||||||
allow="autoplay; encrypted-media; picture-in-picture"
|
|
||||||
allowFullScreen
|
|
||||||
title={current.title}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="max-w-6xl mx-auto px-4 sm:px-6 h-[4.75rem] flex items-center gap-4">
|
|
||||||
<div className="flex items-center gap-3 min-w-0 flex-1">
|
|
||||||
<div className="relative shrink-0">
|
|
||||||
{current.cover_url ? (
|
|
||||||
<img src={current.cover_url} alt="" className="w-11 h-11 rounded-xl object-cover shadow-organic-sm" />
|
|
||||||
) : (
|
|
||||||
<Avatar name={current.title} size="md" className="!rounded-xl" />
|
|
||||||
)}
|
|
||||||
<div className="absolute -bottom-0.5 -right-0.5 flex items-end gap-[2px] h-3 p-[2px] bg-surface rounded-md">
|
|
||||||
<div className="wave-bar" /><div className="wave-bar" /><div className="wave-bar" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="min-w-0">
|
|
||||||
<p className="text-[13px] font-semibold truncate">{current.title}</p>
|
|
||||||
<p className="text-[11px] text-text-secondary truncate">
|
|
||||||
{current.creator?.username} · via {embedInfo.platform === 'youtube' ? 'YouTube' : embedInfo.platform === 'dailymotion' ? 'Dailymotion' : 'SoundCloud'}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={() => setExpanded(!expanded)}
|
|
||||||
className="w-11 h-11 rounded-full bg-gradient-to-br from-primary to-[#7B6AEF] text-white flex items-center justify-center hover:shadow-[0_2px_20px_rgba(91,76,219,0.4)] transition-all active:scale-95 cursor-pointer"
|
|
||||||
>
|
|
||||||
{expanded ? <Minimize2 size={17} /> : <Maximize2 size={17} />}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Native audio player
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed bottom-0 left-0 right-0 z-50 glass border-t border-border/60 shadow-[0_-4px_30px_rgba(30,27,51,0.08)]">
|
<div className="fixed bottom-0 left-0 right-0 z-50 glass border-t border-border/60 shadow-[0_-4px_30px_rgba(30,27,51,0.08)]">
|
||||||
{/* Progress bar */}
|
{/* Progress bar (native audio only) */}
|
||||||
<div className="relative h-[3px] bg-border-light cursor-pointer group" onClick={(e) => {
|
{!isExternal && (
|
||||||
const rect = e.currentTarget.getBoundingClientRect()
|
<div className="relative h-[3px] bg-border-light cursor-pointer group" onClick={(e) => {
|
||||||
const pct = (e.clientX - rect.left) / rect.width
|
const rect = e.currentTarget.getBoundingClientRect()
|
||||||
seek(pct * duration)
|
const pct = (e.clientX - rect.left) / rect.width
|
||||||
}}>
|
seek(pct * duration)
|
||||||
<div
|
}}>
|
||||||
className="absolute inset-y-0 left-0 bg-gradient-to-r from-primary to-[#7B6AEF] transition-all rounded-full"
|
<div
|
||||||
style={{ width: `${duration ? (progress / duration) * 100 : 0}%` }}
|
className="absolute inset-y-0 left-0 bg-gradient-to-r from-primary to-[#7B6AEF] transition-all rounded-full"
|
||||||
/>
|
style={{ width: `${duration ? (progress / duration) * 100 : 0}%` }}
|
||||||
<div
|
/>
|
||||||
className="absolute top-1/2 -translate-y-1/2 w-3 h-3 rounded-full bg-primary shadow-[0_0_0_3px_rgba(91,76,219,0.2)] opacity-0 group-hover:opacity-100 transition-opacity"
|
<div
|
||||||
style={{ left: `${duration ? (progress / duration) * 100 : 0}%`, marginLeft: '-6px' }}
|
className="absolute top-1/2 -translate-y-1/2 w-3 h-3 rounded-full bg-primary shadow-[0_0_0_3px_rgba(91,76,219,0.2)] opacity-0 group-hover:opacity-100 transition-opacity"
|
||||||
/>
|
style={{ left: `${duration ? (progress / duration) * 100 : 0}%`, marginLeft: '-6px' }}
|
||||||
</div>
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="max-w-6xl mx-auto px-4 sm:px-6 h-[4.25rem] flex items-center gap-4">
|
<div className="max-w-6xl mx-auto px-4 sm:px-6 h-[4.25rem] flex items-center gap-4">
|
||||||
{/* Track info */}
|
{/* Track info */}
|
||||||
@ -101,37 +51,49 @@ export function PlayerBar() {
|
|||||||
|
|
||||||
{/* Controls */}
|
{/* Controls */}
|
||||||
<div className="flex items-center gap-2.5">
|
<div className="flex items-center gap-2.5">
|
||||||
<button className="text-text-secondary hover:text-primary transition-colors cursor-pointer p-1" onClick={() => seek(Math.max(0, progress - 15))}>
|
{!isExternal && (
|
||||||
<SkipBack size={17} />
|
<button className="text-text-secondary hover:text-primary transition-colors cursor-pointer p-1" onClick={() => seek(Math.max(0, progress - 15))}>
|
||||||
</button>
|
<SkipBack size={17} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={toggle}
|
onClick={toggle}
|
||||||
className="w-11 h-11 rounded-full bg-gradient-to-br from-primary to-[#7B6AEF] text-white flex items-center justify-center hover:shadow-[0_2px_20px_rgba(91,76,219,0.4)] transition-all active:scale-95 cursor-pointer"
|
className="w-11 h-11 rounded-full bg-gradient-to-br from-primary to-[#7B6AEF] text-white flex items-center justify-center hover:shadow-[0_2px_20px_rgba(91,76,219,0.4)] transition-all active:scale-95 cursor-pointer"
|
||||||
>
|
>
|
||||||
{isPlaying ? <Pause size={17} /> : <Play size={17} className="ml-0.5" />}
|
{isPlaying ? <Pause size={17} /> : <Play size={17} className="ml-0.5" />}
|
||||||
</button>
|
</button>
|
||||||
<button className="text-text-secondary hover:text-primary transition-colors cursor-pointer p-1" onClick={() => seek(Math.min(duration, progress + 15))}>
|
{!isExternal && (
|
||||||
<SkipForward size={17} />
|
<button className="text-text-secondary hover:text-primary transition-colors cursor-pointer p-1" onClick={() => seek(Math.min(duration, progress + 15))}>
|
||||||
</button>
|
<SkipForward size={17} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Volume & time */}
|
{/* Volume & time */}
|
||||||
<div className="hidden sm:flex items-center gap-3 flex-1 justify-end">
|
<div className="hidden sm:flex items-center gap-3 flex-1 justify-end">
|
||||||
<span className="text-[11px] text-text-secondary tabular-nums font-medium">
|
{!isExternal ? (
|
||||||
{formatDuration(progress)} / {formatDuration(duration)}
|
<>
|
||||||
</span>
|
<span className="text-[11px] text-text-secondary tabular-nums font-medium">
|
||||||
<button onClick={() => setVolume(volume === 0 ? 0.8 : 0)} className="text-text-secondary hover:text-primary transition-colors cursor-pointer">
|
{formatDuration(progress)} / {formatDuration(duration)}
|
||||||
{volume === 0 ? <VolumeX size={15} /> : <Volume2 size={15} />}
|
</span>
|
||||||
</button>
|
<button onClick={() => setVolume(volume === 0 ? 0.8 : 0)} className="text-text-secondary hover:text-primary transition-colors cursor-pointer">
|
||||||
<input
|
{volume === 0 ? <VolumeX size={15} /> : <Volume2 size={15} />}
|
||||||
type="range"
|
</button>
|
||||||
min={0}
|
<input
|
||||||
max={1}
|
type="range"
|
||||||
step={0.01}
|
min={0}
|
||||||
value={volume}
|
max={1}
|
||||||
onChange={(e) => setVolume(parseFloat(e.target.value))}
|
step={0.01}
|
||||||
className="w-20"
|
value={volume}
|
||||||
/>
|
onChange={(e) => setVolume(parseFloat(e.target.value))}
|
||||||
|
className="w-20"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<span className="text-[11px] text-text-secondary font-medium">
|
||||||
|
{duration > 0 && formatDuration(duration)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -4,7 +4,6 @@ import { Play, Pause, Heart, MessageCircle, Clock, Share2 } from 'lucide-react'
|
|||||||
import { supabase } from '@/lib/supabase'
|
import { supabase } from '@/lib/supabase'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
import { usePlayerStore } from '@/stores/player'
|
import { usePlayerStore } from '@/stores/player'
|
||||||
import { getEmbedInfo } from '@/lib/embed'
|
|
||||||
import type { Podcast, Comment } from '@/types'
|
import type { Podcast, Comment } from '@/types'
|
||||||
import { formatDuration, timeAgo } from '@/lib/utils'
|
import { formatDuration, timeAgo } from '@/lib/utils'
|
||||||
import { Avatar } from '@/components/ui/Avatar'
|
import { Avatar } from '@/components/ui/Avatar'
|
||||||
@ -164,23 +163,6 @@ export function PodcastDetail() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Embedded player for external content */}
|
|
||||||
{(() => {
|
|
||||||
const embed = getEmbedInfo(podcast.audio_url)
|
|
||||||
if (!embed) return null
|
|
||||||
return (
|
|
||||||
<div className="rounded-2xl overflow-hidden shadow-md border border-border/50 aspect-video">
|
|
||||||
<iframe
|
|
||||||
src={embed.embedUrl.replace('autoplay=1', 'autoplay=0')}
|
|
||||||
className="w-full h-full"
|
|
||||||
allow="autoplay; encrypted-media; picture-in-picture"
|
|
||||||
allowFullScreen
|
|
||||||
title={podcast.title}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})()}
|
|
||||||
|
|
||||||
{podcast.description && (
|
{podcast.description && (
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-lg font-heading font-bold mb-2">Description</h2>
|
<h2 className="text-lg font-heading font-bold mb-2">Description</h2>
|
||||||
|
|||||||
@ -1,6 +1,26 @@
|
|||||||
import { create } from 'zustand'
|
import { create } from 'zustand'
|
||||||
import type { Podcast } from '@/types'
|
import type { Podcast } from '@/types'
|
||||||
import { isExternalUrl } from '@/lib/embed'
|
import { isExternalUrl, getEmbedInfo } from '@/lib/embed'
|
||||||
|
|
||||||
|
// Hidden iframe manager for external content
|
||||||
|
let hiddenIframe: HTMLIFrameElement | null = null
|
||||||
|
|
||||||
|
function destroyIframe() {
|
||||||
|
if (hiddenIframe) {
|
||||||
|
hiddenIframe.remove()
|
||||||
|
hiddenIframe = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createHiddenIframe(embedUrl: string) {
|
||||||
|
destroyIframe()
|
||||||
|
const iframe = document.createElement('iframe')
|
||||||
|
iframe.src = embedUrl
|
||||||
|
iframe.allow = 'autoplay; encrypted-media'
|
||||||
|
iframe.style.cssText = 'position:fixed;width:1px;height:1px;left:-10px;top:-10px;opacity:0;pointer-events:none;'
|
||||||
|
document.body.appendChild(iframe)
|
||||||
|
hiddenIframe = iframe
|
||||||
|
}
|
||||||
|
|
||||||
interface PlayerState {
|
interface PlayerState {
|
||||||
current: Podcast | null
|
current: Podcast | null
|
||||||
@ -33,8 +53,11 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
|
|||||||
const { audio, current } = get()
|
const { audio, current } = get()
|
||||||
const external = isExternalUrl(podcast.audio_url)
|
const external = isExternalUrl(podcast.audio_url)
|
||||||
|
|
||||||
|
// Resume same podcast
|
||||||
if (current?.id === podcast.id) {
|
if (current?.id === podcast.id) {
|
||||||
if (external) {
|
if (external) {
|
||||||
|
const embed = getEmbedInfo(podcast.audio_url)
|
||||||
|
if (embed) createHiddenIframe(embed.embedUrl)
|
||||||
set({ isPlaying: true })
|
set({ isPlaying: true })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -45,13 +68,17 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Stop previous
|
||||||
if (audio) {
|
if (audio) {
|
||||||
audio.pause()
|
audio.pause()
|
||||||
audio.removeAttribute('src')
|
audio.removeAttribute('src')
|
||||||
}
|
}
|
||||||
|
destroyIframe()
|
||||||
|
|
||||||
// External URLs are handled by embed iframe, not HTMLAudioElement
|
// External: play via hidden iframe
|
||||||
if (external) {
|
if (external) {
|
||||||
|
const embed = getEmbedInfo(podcast.audio_url)
|
||||||
|
if (embed) createHiddenIframe(embed.embedUrl)
|
||||||
set({ audio: null, current: podcast, isPlaying: true, isExternal: true, progress: 0, duration: podcast.duration_seconds || 0 })
|
set({ audio: null, current: podcast, isPlaying: true, isExternal: true, progress: 0, duration: podcast.duration_seconds || 0 })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -74,7 +101,17 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
|
|||||||
},
|
},
|
||||||
|
|
||||||
toggle: () => {
|
toggle: () => {
|
||||||
const { audio, isPlaying } = get()
|
const { audio, isPlaying, isExternal, current } = get()
|
||||||
|
if (isExternal) {
|
||||||
|
if (isPlaying) {
|
||||||
|
destroyIframe()
|
||||||
|
} else {
|
||||||
|
const embed = current ? getEmbedInfo(current.audio_url) : null
|
||||||
|
if (embed) createHiddenIframe(embed.embedUrl)
|
||||||
|
}
|
||||||
|
set({ isPlaying: !isPlaying })
|
||||||
|
return
|
||||||
|
}
|
||||||
if (!audio) return
|
if (!audio) return
|
||||||
if (isPlaying) {
|
if (isPlaying) {
|
||||||
audio.pause()
|
audio.pause()
|
||||||
@ -85,6 +122,8 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
|
|||||||
},
|
},
|
||||||
|
|
||||||
pause: () => {
|
pause: () => {
|
||||||
|
const { isExternal } = get()
|
||||||
|
if (isExternal) destroyIframe()
|
||||||
get().audio?.pause()
|
get().audio?.pause()
|
||||||
set({ isPlaying: false })
|
set({ isPlaying: false })
|
||||||
},
|
},
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user