Compare commits
3 Commits
a7c4aa5608
...
1ae040cdd3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1ae040cdd3 | ||
|
|
569b5a5846 | ||
|
|
49dcfb28de |
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
2
api/.dockerignore
Normal file
@ -0,0 +1,2 @@
|
||||
node_modules
|
||||
.env
|
||||
4
api/.env.example
Normal file
4
api/.env.example
Normal file
@ -0,0 +1,4 @@
|
||||
SPOTIFY_CLIENT_ID=
|
||||
SPOTIFY_CLIENT_SECRET=
|
||||
YOUTUBE_API_KEY=
|
||||
PORT=3001
|
||||
8
api/Dockerfile
Normal file
8
api/Dockerfile
Normal 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
15
api/bun.lock
Normal 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
12
api/package.json
Normal 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
234
api/src/index.ts
Normal 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,
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
@ -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)
|
||||
const res = await fetch(`${API_URL}/api/metadata?url=${encodeURIComponent(url)}`)
|
||||
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,
|
||||
}
|
||||
}
|
||||
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">
|
||||
|
||||
@ -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(() => {
|
||||
// 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;width:1px;height:1px;left:-10px;top:-10px;opacity:0;pointer-events:none;'
|
||||
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)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user