Compare commits

...

3 Commits

Author SHA1 Message Date
ordinarthur
1ae040cdd3 feat: add backend API (Hono/Bun) for metadata proxying + fullstack K8s deploy
Some checks failed
Build & Deploy / build-and-deploy (push) Failing after 2m26s
2026-04-13 11:24:40 +02:00
ordinarthur
569b5a5846 add a little api 2026-04-13 11:20:47 +02:00
ordinarthur
49dcfb28de add youtube iframe 2026-04-13 11:02:38 +02:00
16 changed files with 455 additions and 152 deletions

View File

@ -1,2 +1,3 @@
VITE_SUPABASE_URL=https://your-project.supabase.co
VITE_SUPABASE_ANON_KEY=your-anon-key
VITE_API_URL=http://localhost:3001

View File

@ -6,7 +6,8 @@ on:
env:
REGISTRY: git.arthurbarre.fr
IMAGE: ordinarthur/wetalk
IMAGE_FRONT: ordinarthur/wetalk
IMAGE_API: ordinarthur/wetalk-api
NAMESPACE: wetalk
jobs:
@ -23,17 +24,27 @@ jobs:
username: ordinarthur
password: ${{ secrets.REGISTRY_PASSWORD }}
- name: Build and push image
- name: Build and push frontend
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: |
${{ env.REGISTRY }}/${{ env.IMAGE }}:latest
${{ env.REGISTRY }}/${{ env.IMAGE }}:${{ github.sha }}
${{ env.REGISTRY }}/${{ env.IMAGE_FRONT }}:latest
${{ env.REGISTRY }}/${{ env.IMAGE_FRONT }}:${{ github.sha }}
build-args: |
VITE_SUPABASE_URL=${{ secrets.VITE_SUPABASE_URL }}
VITE_SUPABASE_ANON_KEY=${{ secrets.VITE_SUPABASE_ANON_KEY }}
VITE_API_URL=
- name: Build and push API
uses: docker/build-push-action@v5
with:
context: ./api
push: true
tags: |
${{ env.REGISTRY }}/${{ env.IMAGE_API }}:latest
${{ env.REGISTRY }}/${{ env.IMAGE_API }}:${{ github.sha }}
- name: Install kubectl
run: |
@ -57,13 +68,21 @@ jobs:
--docker-password=${{ secrets.REGISTRY_PASSWORD }} \
--dry-run=client -o yaml | kubectl apply -f -
# Create API secrets
kubectl -n $NAMESPACE create secret generic wetalk-api-secrets \
--from-literal=SPOTIFY_CLIENT_ID=${{ secrets.SPOTIFY_CLIENT_ID }} \
--from-literal=SPOTIFY_CLIENT_SECRET=${{ secrets.SPOTIFY_CLIENT_SECRET }} \
--from-literal=YOUTUBE_API_KEY=${{ secrets.YOUTUBE_API_KEY }} \
--dry-run=client -o yaml | kubectl apply -f -
# Apply manifests
kubectl apply -f k8s/service.yml
kubectl apply -f k8s/deployment.yml
# Force rollout with new image
# Force rollout with new images
kubectl -n $NAMESPACE set image deployment/wetalk \
wetalk=$REGISTRY/$IMAGE:${{ github.sha }}
wetalk=$REGISTRY/$IMAGE_FRONT:${{ github.sha }} \
wetalk-api=$REGISTRY/$IMAGE_API:${{ github.sha }}
# Wait for rollout
kubectl -n $NAMESPACE rollout status deployment/wetalk --timeout=120s

View File

@ -5,6 +5,7 @@ RUN npm ci
COPY . .
ARG VITE_SUPABASE_URL
ARG VITE_SUPABASE_ANON_KEY
ARG VITE_API_URL
RUN ./node_modules/.bin/vite build
FROM nginx:alpine

2
api/.dockerignore Normal file
View File

@ -0,0 +1,2 @@
node_modules
.env

4
api/.env.example Normal file
View File

@ -0,0 +1,4 @@
SPOTIFY_CLIENT_ID=
SPOTIFY_CLIENT_SECRET=
YOUTUBE_API_KEY=
PORT=3001

8
api/Dockerfile Normal file
View File

@ -0,0 +1,8 @@
FROM oven/bun:1-alpine
WORKDIR /app
COPY package.json bun.lock* ./
RUN bun install --frozen-lockfile || bun install
COPY . .
EXPOSE 3001
HEALTHCHECK --interval=30s --timeout=3s CMD wget -q --spider http://localhost:3001/health || exit 1
CMD ["bun", "run", "src/index.ts"]

15
api/bun.lock Normal file
View File

@ -0,0 +1,15 @@
{
"lockfileVersion": 1,
"configVersion": 1,
"workspaces": {
"": {
"name": "wetalk-api",
"dependencies": {
"hono": "^4.7.0",
},
},
},
"packages": {
"hono": ["hono@4.12.12", "", {}, "sha512-p1JfQMKaceuCbpJKAPKVqyqviZdS0eUxH9v82oWo1kb9xjQ5wA6iP3FNVAPDFlz5/p7d45lO+BpSk1tuSZMF4Q=="],
}
}

12
api/package.json Normal file
View File

@ -0,0 +1,12 @@
{
"name": "wetalk-api",
"version": "1.0.0",
"private": true,
"scripts": {
"dev": "bun run --hot src/index.ts",
"start": "bun run src/index.ts"
},
"dependencies": {
"hono": "^4.7.0"
}
}

234
api/src/index.ts Normal file
View File

@ -0,0 +1,234 @@
import { Hono } from 'hono'
import { cors } from 'hono/cors'
const app = new Hono()
app.use('*', cors({
origin: '*',
allowMethods: ['GET', 'POST'],
}))
// ── Spotify token cache ──
let spotifyToken: string | null = null
let spotifyTokenExpiry = 0
const SPOTIFY_CLIENT_ID = process.env.SPOTIFY_CLIENT_ID || ''
const SPOTIFY_CLIENT_SECRET = process.env.SPOTIFY_CLIENT_SECRET || ''
const YOUTUBE_API_KEY = process.env.YOUTUBE_API_KEY || ''
async function getSpotifyToken(): Promise<string | null> {
if (spotifyToken && Date.now() < spotifyTokenExpiry) return spotifyToken
if (!SPOTIFY_CLIENT_ID || !SPOTIFY_CLIENT_SECRET) return null
const res = await fetch('https://accounts.spotify.com/api/token', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
Authorization: `Basic ${btoa(`${SPOTIFY_CLIENT_ID}:${SPOTIFY_CLIENT_SECRET}`)}`,
},
body: 'grant_type=client_credentials',
})
if (!res.ok) return null
const data = await res.json()
spotifyToken = data.access_token
spotifyTokenExpiry = Date.now() + (data.expires_in - 60) * 1000
return spotifyToken
}
// ── URL parsing helpers ──
function parseDuration(iso: string): number {
const match = iso.match(/PT(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?/)
if (!match) return 0
return parseInt(match[1] || '0') * 3600 + parseInt(match[2] || '0') * 60 + parseInt(match[3] || '0')
}
function extractInfo(url: string): { platform: string; id: string; type?: string } | null {
// YouTube
const ytMatch = url.match(/(?:youtube\.com\/(?:watch\?v=|embed\/|shorts\/)|youtu\.be\/)([a-zA-Z0-9_-]{11})/)
if (ytMatch) return { platform: 'youtube', id: ytMatch[1] }
// Dailymotion
const dmMatch = url.match(/(?:dailymotion\.com\/video\/|dai\.ly\/)([a-zA-Z0-9]+)/)
if (dmMatch) return { platform: 'dailymotion', id: dmMatch[1] }
// SoundCloud
if (url.includes('soundcloud.com/')) return { platform: 'soundcloud', id: url }
// Spotify
const spotifyMatch = url.match(/open\.spotify\.com\/(episode|show|track)\/([a-zA-Z0-9]+)/)
if (spotifyMatch) return { platform: 'spotify', id: spotifyMatch[2], type: spotifyMatch[1] }
return null
}
// ── Main endpoint ──
app.get('/api/metadata', async (c) => {
const url = c.req.query('url')
if (!url) return c.json({ error: 'Missing url parameter' }, 400)
const info = extractInfo(url)
if (!info) return c.json({ error: 'Unsupported URL' }, 400)
try {
// ── YouTube ──
if (info.platform === 'youtube') {
if (YOUTUBE_API_KEY) {
const apiUrl = `https://www.googleapis.com/youtube/v3/videos?part=snippet,contentDetails&id=${info.id}&key=${YOUTUBE_API_KEY}`
const res = await fetch(apiUrl)
if (res.ok) {
const data = await res.json()
const video = data.items?.[0]
if (video) {
const s = video.snippet || {}
const thumbs = s.thumbnails || {}
const thumb = thumbs.maxres?.url || thumbs.standard?.url || thumbs.high?.url || thumbs.medium?.url || thumbs.default?.url || ''
return c.json({
title: s.title || '',
description: (s.description || '').slice(0, 500),
thumbnail: thumb,
duration: parseDuration(video.contentDetails?.duration || ''),
audioUrl: url,
platform: 'YouTube',
author: s.channelTitle || '',
})
}
}
}
// Fallback oEmbed
const res = await fetch(`https://www.youtube.com/oembed?url=https://www.youtube.com/watch?v=${info.id}&format=json`)
if (res.ok) {
const data = await res.json()
return c.json({
title: data.title || '',
description: data.author_name ? `Par ${data.author_name}` : '',
thumbnail: `https://img.youtube.com/vi/${info.id}/maxresdefault.jpg`,
duration: 0,
audioUrl: url,
platform: 'YouTube',
author: data.author_name || '',
})
}
}
// ── Spotify ──
if (info.platform === 'spotify') {
const token = await getSpotifyToken()
if (token) {
const contentType = info.type || 'episode'
const endpoint = contentType === 'show'
? `https://api.spotify.com/v1/shows/${info.id}?market=FR`
: contentType === 'track'
? `https://api.spotify.com/v1/tracks/${info.id}?market=FR`
: `https://api.spotify.com/v1/episodes/${info.id}?market=FR`
const res = await fetch(endpoint, {
headers: { Authorization: `Bearer ${token}` },
})
if (res.ok) {
const data = await res.json()
if (contentType === 'episode') {
const images = data.images || data.show?.images || []
return c.json({
title: data.name || '',
description: data.description || '',
thumbnail: images[0]?.url || '',
duration: Math.floor((data.duration_ms || 0) / 1000),
audioUrl: url,
platform: 'Spotify',
author: data.show?.name || data.show?.publisher || '',
})
}
if (contentType === 'show') {
return c.json({
title: data.name || '',
description: data.description || '',
thumbnail: (data.images || [])[0]?.url || '',
duration: 0,
audioUrl: url,
platform: 'Spotify',
author: data.publisher || '',
})
}
if (contentType === 'track') {
const artist = data.artists?.map((a: any) => a.name).join(', ') || ''
return c.json({
title: data.name || '',
description: artist ? `Par ${artist}` : '',
thumbnail: (data.album?.images || [])[0]?.url || '',
duration: Math.floor((data.duration_ms || 0) / 1000),
audioUrl: url,
platform: 'Spotify',
author: artist,
})
}
}
}
// Fallback oEmbed
const res = await fetch(`https://open.spotify.com/oembed?url=${encodeURIComponent(url)}`)
if (res.ok) {
const data = await res.json()
const parts = (data.title || '').split(' - ')
return c.json({
title: parts[0]?.trim() || data.title || '',
description: '',
thumbnail: data.thumbnail_url || '',
duration: 0,
audioUrl: url,
platform: 'Spotify',
author: parts.length > 1 ? parts[parts.length - 1]?.trim() : '',
})
}
}
// ── Dailymotion ──
if (info.platform === 'dailymotion') {
const apiUrl = `https://api.dailymotion.com/video/${info.id}?fields=title,description,duration,thumbnail_720_url,owner.screenname`
const res = await fetch(apiUrl)
if (res.ok) {
const data = await res.json()
const owner = data.owner?.screenname || ''
return c.json({
title: data.title || '',
description: data.description || '',
thumbnail: data.thumbnail_720_url || '',
duration: data.duration || 0,
audioUrl: url,
platform: 'Dailymotion',
author: owner,
})
}
}
// ── SoundCloud ──
if (info.platform === 'soundcloud') {
const res = await fetch(`https://soundcloud.com/oembed?url=${encodeURIComponent(url)}&format=json`)
if (res.ok) {
const data = await res.json()
return c.json({
title: data.title || '',
description: data.description || '',
thumbnail: data.thumbnail_url || '',
duration: 0,
audioUrl: url,
platform: 'SoundCloud',
author: data.author_name || '',
})
}
}
return c.json({ error: 'Could not fetch metadata' }, 404)
} catch (e) {
return c.json({ error: 'Internal error' }, 500)
}
})
app.get('/health', (c) => c.json({ ok: true }))
const port = parseInt(process.env.PORT || '3001')
console.log(`[wetalk-api] listening on :${port}`)
export default {
port,
fetch: app.fetch,
}

View File

@ -12,3 +12,15 @@ services:
- .env
environment:
- CHOKIDAR_USEPOLLING=true
api:
image: oven/bun:1
working_dir: /app
ports:
- "3001:3001"
volumes:
- ./api:/app
- /app/node_modules
env_file:
- ./api/.env
command: bun run src/index.ts

View File

@ -37,5 +37,48 @@ spec:
limits:
memory: "64Mi"
cpu: "100m"
- name: wetalk-api
image: git.arthurbarre.fr/ordinarthur/wetalk-api:latest
ports:
- containerPort: 3001
env:
- name: SPOTIFY_CLIENT_ID
valueFrom:
secretKeyRef:
name: wetalk-api-secrets
key: SPOTIFY_CLIENT_ID
- name: SPOTIFY_CLIENT_SECRET
valueFrom:
secretKeyRef:
name: wetalk-api-secrets
key: SPOTIFY_CLIENT_SECRET
- name: YOUTUBE_API_KEY
valueFrom:
secretKeyRef:
name: wetalk-api-secrets
key: YOUTUBE_API_KEY
- name: PORT
value: "3001"
livenessProbe:
httpGet:
path: /health
port: 3001
initialDelaySeconds: 5
periodSeconds: 30
readinessProbe:
httpGet:
path: /health
port: 3001
initialDelaySeconds: 3
periodSeconds: 10
resources:
requests:
memory: "32Mi"
cpu: "10m"
limits:
memory: "128Mi"
cpu: "200m"
imagePullSecrets:
- name: gitea-registry

View File

@ -3,6 +3,14 @@ server {
root /usr/share/nginx/html;
index index.html;
location /api/ {
proxy_pass http://localhost:3001;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location / {
try_files $uri $uri/ /index.html;
}

View File

@ -1,15 +1,17 @@
import { useRef, useState, useCallback } 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 { formatDuration } from '@/lib/utils'
import { publicUrl } from '@/lib/storage'
import { Avatar } from '@/components/ui/Avatar'
import { isExternalUrl, getEmbedInfo } from '@/lib/embed'
export function PlayerBar() {
const { current, isPlaying, isExternal, progress, duration, volume, toggle, seek, setVolume } = usePlayerStore()
const seekRef = useRef<HTMLDivElement>(null)
const [isDragging, setIsDragging] = useState(false)
const [dragPct, setDragPct] = useState(0)
const [ytExpanded, setYtExpanded] = useState(false)
const calcPct = useCallback((clientX: number) => {
if (!seekRef.current) return 0
@ -19,6 +21,9 @@ export function PlayerBar() {
if (!current) return null
const embedInfo = isExternal && current ? getEmbedInfo(current.audio_url) : null
const isYouTube = embedInfo?.platform === 'youtube'
const isSpotify = embedInfo?.platform === 'spotify'
const pct = duration > 0 ? (progress / duration) * 100 : 0
const displayPct = isDragging ? dragPct : pct
// Allow seeking for native audio and YouTube (via IFrame API)
@ -72,6 +77,41 @@ export function PlayerBar() {
const currentTime = isDragging ? (dragPct / 100) * duration : progress
return (
<>
{/* YouTube player - mini or expanded */}
{isYouTube && (
<div
className={`fixed z-[60] rounded-xl overflow-hidden shadow-[0_4px_20px_rgba(0,0,0,0.3)] transition-all duration-300 ${
ytExpanded
? 'inset-4 bottom-[106px]'
: 'bottom-[106px] right-4'
}`}
style={ytExpanded ? {} : { width: 200, height: 150 }}
>
<div id="yt-player-portal" className="w-full h-full [&_iframe]:!w-full [&_iframe]:!h-full [&_iframe]:!border-0" />
{/* Gradient overlay to hide YouTube title bar */}
<div className="absolute inset-x-0 top-0 h-10 bg-gradient-to-b from-black/80 to-transparent pointer-events-none" />
<button
onClick={() => setYtExpanded(!ytExpanded)}
className="absolute top-2 right-2 z-10 w-8 h-8 rounded-lg bg-black/60 hover:bg-black/80 flex items-center justify-center text-white transition-colors cursor-pointer"
>
{ytExpanded ? <Minimize2 size={14} /> : <Maximize2 size={14} />}
</button>
</div>
)}
{/* Spotify player - mini floating */}
{isSpotify && embedInfo && (
<div className="fixed bottom-[106px] right-4 z-[60] rounded-xl overflow-hidden shadow-[0_4px_20px_rgba(0,0,0,0.3)]">
<iframe
src={embedInfo.embedUrl}
width="300"
height="152"
allow="autoplay; clipboard-write; encrypted-media; fullscreen; picture-in-picture"
loading="lazy"
style={{ border: 0, borderRadius: 12 }}
/>
</div>
)}
<div className="fixed bottom-0 left-0 right-0 z-50 bg-[#1E1B33] text-white shadow-[0_-2px_20px_rgba(0,0,0,0.3)]">
<div className="max-w-6xl mx-auto px-4 sm:px-6">
{/* Main row */}
@ -192,5 +232,6 @@ export function PlayerBar() {
</div>}
</div>
</div>
</>
)
}

View File

@ -1,5 +1,5 @@
export type EmbedInfo = {
platform: 'youtube' | 'dailymotion' | 'soundcloud'
platform: 'youtube' | 'dailymotion' | 'soundcloud' | 'spotify'
id: string
embedUrl: string
}
@ -38,6 +38,20 @@ export function getEmbedInfo(url: string): EmbedInfo | null {
}
}
// Spotify (episodes, shows, tracks)
const spotifyMatch = url.match(
/open\.spotify\.com\/(episode|show|track)\/([a-zA-Z0-9]+)/,
)
if (spotifyMatch) {
const type = spotifyMatch[1]
const id = spotifyMatch[2]
return {
platform: 'spotify',
id,
embedUrl: `https://open.spotify.com/embed/${type}/${id}?utm_source=generator&theme=0`,
}
}
return null
}

View File

@ -18,148 +18,16 @@ interface OEmbedData {
author: string
}
function extractVideoId(url: string): { platform: string; id: string } | null {
// YouTube
const ytMatch = url.match(
/(?:youtube\.com\/(?:watch\?v=|embed\/|shorts\/)|youtu\.be\/)([a-zA-Z0-9_-]{11})/,
)
if (ytMatch) return { platform: 'youtube', id: ytMatch[1] }
// Dailymotion
const dmMatch = url.match(
/(?:dailymotion\.com\/video\/|dai\.ly\/)([a-zA-Z0-9]+)/,
)
if (dmMatch) return { platform: 'dailymotion', id: dmMatch[1] }
// SoundCloud (handled via oEmbed, no ID extraction needed)
if (url.includes('soundcloud.com/')) return { platform: 'soundcloud', id: url }
return null
}
// Parse ISO 8601 duration (PT1H2M30S) to seconds
function parseDuration(iso: string): number {
const match = iso.match(/PT(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?/)
if (!match) return 0
const h = parseInt(match[1] || '0')
const m = parseInt(match[2] || '0')
const s = parseInt(match[3] || '0')
return h * 3600 + m * 60 + s
}
const YT_API_KEY = import.meta.env.VITE_YOUTUBE_API_KEY || ''
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:3001'
async function fetchVideoMeta(url: string): Promise<OEmbedData | null> {
const info = extractVideoId(url)
if (!info) return null
try {
if (info.platform === 'youtube') {
// Use YouTube Data API v3 if key is available
if (YT_API_KEY) {
const apiUrl = `https://www.googleapis.com/youtube/v3/videos?part=snippet,contentDetails,statistics&id=${info.id}&key=${YT_API_KEY}`
const res = await fetch(apiUrl)
if (res.ok) {
const data = await res.json()
const video = data.items?.[0]
if (video) {
const snippet = video.snippet || {}
const contentDetails = video.contentDetails || {}
const stats = video.statistics || {}
// Best thumbnail: maxres > standard > high > medium > default
const thumbs = snippet.thumbnails || {}
const thumb = thumbs.maxres?.url || thumbs.standard?.url || thumbs.high?.url || thumbs.medium?.url || thumbs.default?.url || ''
const duration = parseDuration(contentDetails.duration || '')
const viewCount = parseInt(stats.viewCount || '0')
return {
title: snippet.title || '',
description: snippet.description?.slice(0, 500) || '',
thumbnail: thumb,
duration,
audioUrl: url,
platform: 'YouTube',
author: snippet.channelTitle || '',
}
}
}
}
// Fallback to oEmbed (no duration but basic metadata)
const oembedUrl = `https://www.youtube.com/oembed?url=https://www.youtube.com/watch?v=${info.id}&format=json`
const res = await fetch(oembedUrl)
if (!res.ok) return null
const data = await res.json()
return {
title: data.title || '',
description: data.author_name ? `Par ${data.author_name}` : '',
thumbnail: `https://img.youtube.com/vi/${info.id}/maxresdefault.jpg`,
duration: 0,
audioUrl: url,
platform: 'YouTube',
author: data.author_name || '',
}
}
if (info.platform === 'dailymotion') {
// Dailymotion public Data API — returns duration, description, tags, author
const apiUrl = `https://api.dailymotion.com/video/${info.id}?fields=title,description,duration,thumbnail_720_url,owner.screenname,tags,created_time`
const res = await fetch(apiUrl)
if (!res.ok) {
// Fallback to oEmbed
const oembedUrl = `https://www.dailymotion.com/services/oembed?url=https://www.dailymotion.com/video/${info.id}&format=json`
const oeRes = await fetch(oembedUrl)
if (!oeRes.ok) return null
const oeData = await oeRes.json()
return {
title: oeData.title || '',
description: '',
thumbnail: oeData.thumbnail_url || '',
duration: 0,
audioUrl: url,
platform: 'Dailymotion',
author: oeData.author_name || '',
}
}
const data = await res.json()
const owner = data.owner?.screenname || ''
return {
title: data.title || '',
description: data.description || (owner ? `Par ${owner}` : ''),
thumbnail: data.thumbnail_720_url || `https://www.dailymotion.com/thumbnail/video/${info.id}`,
duration: data.duration || 0,
audioUrl: url,
platform: 'Dailymotion',
author: owner,
}
}
if (info.platform === 'soundcloud') {
const oembedUrl = `https://soundcloud.com/oembed?url=${encodeURIComponent(url)}&format=json`
const res = await fetch(oembedUrl)
if (!res.ok) return null
const data = await res.json()
// Extract duration from the iframe embed (SoundCloud embeds sometimes include it)
// Parse the HTML to get the widget URL for potential API calls
const author = data.author_name || ''
return {
title: data.title || '',
description: data.description || (author ? `Par ${author}` : ''),
thumbnail: data.thumbnail_url || '',
duration: 0,
audioUrl: url,
platform: 'SoundCloud',
author,
}
}
const res = await fetch(`${API_URL}/api/metadata?url=${encodeURIComponent(url)}`)
if (!res.ok) return null
return await res.json()
} catch {
return null
}
return null
}
export function Upload() {
@ -446,6 +314,7 @@ export function Upload() {
</p>
<div className="flex gap-1.5 mt-4">
<span className="text-[11px] font-medium px-2 py-0.5 rounded-full bg-mint/10 text-mint">YouTube</span>
<span className="text-[11px] font-medium px-2 py-0.5 rounded-full bg-mint/10 text-mint">Spotify</span>
<span className="text-[11px] font-medium px-2 py-0.5 rounded-full bg-mint/10 text-mint">Dailymotion</span>
<span className="text-[11px] font-medium px-2 py-0.5 rounded-full bg-mint/10 text-mint">SoundCloud</span>
</div>
@ -602,7 +471,7 @@ export function Upload() {
label="URL de la video ou du podcast"
value={externalUrl}
onChange={(e) => setExternalUrl(e.target.value)}
placeholder="https://youtube.com/watch?v=... ou https://dai.ly/..."
placeholder="https://youtube.com/watch?v=... ou https://open.spotify.com/episode/..."
/>
</div>
<div className="flex items-end">

View File

@ -50,7 +50,15 @@ function destroyYtPlayer() {
clearProgressInterval()
try { ytPlayer?.destroy() } catch { /* ignore */ }
ytPlayer = null
if (ytContainer) { ytContainer.remove(); ytContainer = null }
if (ytContainer) {
// If it's the portal, just clear contents; otherwise remove from DOM
if (ytContainer.id === 'yt-player-portal') {
ytContainer.innerHTML = ''
} else {
ytContainer.remove()
}
ytContainer = null
}
}
function destroyIframe() {
@ -178,20 +186,26 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
// Wait for previous save to complete, then fetch saved progress
savePrevPromise.then(() => fetchSavedProgress(podcast.id)).then((savedTime) => {
loadYouTubeAPI().then(() => {
ytContainer = document.createElement('div')
ytContainer.style.cssText = 'position:fixed;width:1px;height:1px;left:-10px;top:-10px;opacity:0;pointer-events:none;'
// Use portal target if available, otherwise create visible container
const portalTarget = document.getElementById('yt-player-portal')
if (portalTarget) {
portalTarget.innerHTML = ''
ytContainer = portalTarget as HTMLDivElement
} else {
ytContainer = document.createElement('div')
ytContainer.style.cssText = 'position:fixed;bottom:90px;right:16px;width:200px;height:200px;z-index:60;border-radius:12px;overflow:hidden;box-shadow:0 4px 20px rgba(0,0,0,0.3);'
document.body.appendChild(ytContainer)
}
const playerDiv = document.createElement('div')
playerDiv.id = 'yt-player-' + Date.now()
ytContainer.appendChild(playerDiv)
document.body.appendChild(ytContainer)
ytPlayer = new window.YT.Player(playerDiv.id, {
videoId: embed.id,
playerVars: {
autoplay: 1,
controls: 0,
disablekb: 1,
fs: 0,
controls: 1,
fs: 1,
modestbranding: 1,
rel: 0,
start: savedTime > 0 ? Math.floor(savedTime) : undefined,
@ -236,6 +250,12 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
return
}
// ── Spotify: embed is rendered in PlayerBar, no hidden iframe needed ──
if (external && embed?.platform === 'spotify') {
set({ audio: null, current: podcast, isPlaying: true, isExternal: true, progress: 0, duration: podcast.duration_seconds || 0 })
return
}
// ── Other external (Dailymotion, SoundCloud): hidden iframe ──
if (external && embed) {
createHiddenIframe(embed.embedUrl)