313 lines
17 KiB
TypeScript
313 lines
17 KiB
TypeScript
import React, { useState, useEffect } from 'react';
|
|
import { Song } from '../types';
|
|
import { songsApi, getAudioUrl } from '../services/api';
|
|
import { useAuth } from '../context/AuthContext';
|
|
import { ArrowLeft, Play, Pause, Heart, Share2, MoreHorizontal, ThumbsDown, Music as MusicIcon, Edit3, Eye } from 'lucide-react';
|
|
import { ShareModal } from './ShareModal';
|
|
|
|
interface SongProfileProps {
|
|
songId: string;
|
|
onBack: () => void;
|
|
onPlay: (song: Song, list?: Song[]) => void;
|
|
onNavigateToProfile: (username: string) => void;
|
|
currentSong?: Song | null;
|
|
isPlaying?: boolean;
|
|
likedSongIds?: Set<string>;
|
|
onToggleLike?: (songId: string) => void;
|
|
}
|
|
|
|
const updateMetaTags = (song: Song) => {
|
|
const baseUrl = window.location.origin;
|
|
const songUrl = `${baseUrl}/song/${song.id}`;
|
|
const title = `${song.title} by ${song.creator || 'Unknown Artist'} | ACE-Step UI`;
|
|
const description = `Listen to "${song.title}" - ${song.style}. ${song.viewCount || 0} plays, ${song.likeCount || 0} likes. Create your own AI music with ACE-Step UI.`;
|
|
|
|
document.title = title;
|
|
|
|
const updateOrCreateMeta = (selector: string, attribute: string, value: string) => {
|
|
let element = document.querySelector(selector) as HTMLMetaElement;
|
|
if (!element) {
|
|
element = document.createElement('meta');
|
|
const [attr, attrValue] = selector.replace(/[\[\]'"]/g, '').split('=');
|
|
if (attr === 'property') element.setAttribute('property', attrValue);
|
|
else if (attr === 'name') element.setAttribute('name', attrValue);
|
|
document.head.appendChild(element);
|
|
}
|
|
element.setAttribute(attribute, value);
|
|
};
|
|
|
|
updateOrCreateMeta('meta[name="description"]', 'content', description);
|
|
updateOrCreateMeta('meta[name="title"]', 'content', title);
|
|
|
|
updateOrCreateMeta('meta[property="og:type"]', 'content', 'music.song');
|
|
updateOrCreateMeta('meta[property="og:url"]', 'content', songUrl);
|
|
updateOrCreateMeta('meta[property="og:title"]', 'content', title);
|
|
updateOrCreateMeta('meta[property="og:description"]', 'content', description);
|
|
updateOrCreateMeta('meta[property="og:image"]', 'content', song.coverUrl);
|
|
updateOrCreateMeta('meta[property="og:image:width"]', 'content', '400');
|
|
updateOrCreateMeta('meta[property="og:image:height"]', 'content', '400');
|
|
updateOrCreateMeta('meta[property="og:audio"]', 'content', song.audioUrl || '');
|
|
updateOrCreateMeta('meta[property="og:audio:type"]', 'content', 'audio/mpeg');
|
|
|
|
updateOrCreateMeta('meta[name="twitter:card"]', 'content', 'summary_large_image');
|
|
updateOrCreateMeta('meta[name="twitter:url"]', 'content', songUrl);
|
|
updateOrCreateMeta('meta[name="twitter:title"]', 'content', title);
|
|
updateOrCreateMeta('meta[name="twitter:description"]', 'content', description);
|
|
updateOrCreateMeta('meta[name="twitter:image"]', 'content', song.coverUrl);
|
|
|
|
updateOrCreateMeta('meta[property="music:duration"]', 'content', String(song.duration || 0));
|
|
updateOrCreateMeta('meta[property="music:musician"]', 'content', song.creator || 'Unknown Artist');
|
|
};
|
|
|
|
const resetMetaTags = () => {
|
|
document.title = 'ACE-Step UI - Local AI Music Generator';
|
|
const defaultDescription = 'Create original music with AI locally. Generate songs in any style with custom lyrics and professional quality using ACE-Step.';
|
|
const defaultImage = '/og-image.png';
|
|
|
|
const updateMeta = (selector: string, content: string) => {
|
|
const element = document.querySelector(selector) as HTMLMetaElement;
|
|
if (element) element.setAttribute('content', content);
|
|
};
|
|
|
|
updateMeta('meta[name="description"]', defaultDescription);
|
|
updateMeta('meta[property="og:title"]', 'ACE-Step UI - Local AI Music Generator');
|
|
updateMeta('meta[property="og:description"]', defaultDescription);
|
|
updateMeta('meta[property="og:image"]', defaultImage);
|
|
updateMeta('meta[property="og:type"]', 'website');
|
|
updateMeta('meta[name="twitter:title"]', 'ACE-Step UI - Local AI Music Generator');
|
|
updateMeta('meta[name="twitter:description"]', defaultDescription);
|
|
updateMeta('meta[name="twitter:image"]', defaultImage);
|
|
};
|
|
|
|
export const SongProfile: React.FC<SongProfileProps> = ({ songId, onBack, onPlay, onNavigateToProfile, currentSong, isPlaying, likedSongIds = new Set(), onToggleLike }) => {
|
|
const { user, token } = useAuth();
|
|
const [song, setSong] = useState<Song | null>(null);
|
|
const [loading, setLoading] = useState(true);
|
|
const [shareModalOpen, setShareModalOpen] = useState(false);
|
|
|
|
const isCurrentSong = song && currentSong?.id === song.id;
|
|
const isCurrentlyPlaying = isCurrentSong && isPlaying;
|
|
const isLiked = song ? likedSongIds.has(song.id) : false;
|
|
|
|
useEffect(() => {
|
|
loadSongData();
|
|
return () => resetMetaTags();
|
|
}, [songId]);
|
|
|
|
useEffect(() => {
|
|
if (song) {
|
|
updateMetaTags(song);
|
|
}
|
|
}, [song]);
|
|
|
|
const loadSongData = async () => {
|
|
setLoading(true);
|
|
try {
|
|
const response = await songsApi.getFullSong(songId, token);
|
|
|
|
const transformedSong: Song = {
|
|
id: response.song.id,
|
|
title: response.song.title,
|
|
lyrics: response.song.lyrics,
|
|
style: response.song.style,
|
|
coverUrl: `https://picsum.photos/seed/${response.song.id}/400/400`,
|
|
duration: response.song.duration
|
|
? `${Math.floor(response.song.duration / 60)}:${String(Math.floor(response.song.duration % 60)).padStart(2, '0')}`
|
|
: '0:00',
|
|
createdAt: new Date(response.song.created_at),
|
|
tags: response.song.tags || [],
|
|
audioUrl: getAudioUrl(response.song.audio_url, response.song.id),
|
|
isPublic: response.song.is_public,
|
|
likeCount: response.song.like_count || 0,
|
|
viewCount: response.song.view_count || 0,
|
|
userId: response.song.user_id,
|
|
creator: response.song.creator,
|
|
creator_avatar: response.song.creator_avatar,
|
|
};
|
|
|
|
setSong(transformedSong);
|
|
} catch (error) {
|
|
console.error('Failed to load song:', error);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
if (loading) {
|
|
return (
|
|
<div className="flex items-center justify-center h-full bg-zinc-50 dark:bg-black">
|
|
<div className="text-zinc-500 dark:text-zinc-400 flex items-center gap-2">
|
|
<div className="w-4 h-4 border-2 border-zinc-400 border-t-transparent rounded-full animate-spin" />
|
|
Loading song...
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (!song) {
|
|
return (
|
|
<div className="flex flex-col items-center justify-center h-full gap-4 bg-zinc-50 dark:bg-black">
|
|
<div className="text-zinc-500 dark:text-zinc-400">Song not found</div>
|
|
<button onClick={onBack} className="px-4 py-2 bg-zinc-200 dark:bg-zinc-800 hover:bg-zinc-300 dark:hover:bg-zinc-700 rounded-lg text-zinc-900 dark:text-white transition-colors">
|
|
Go Back
|
|
</button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="w-full h-full flex flex-col bg-zinc-50 dark:bg-black overflow-hidden">
|
|
{/* Header */}
|
|
<div className="border-b border-zinc-200 dark:border-zinc-800 px-4 md:px-6 py-4 flex-shrink-0">
|
|
<button
|
|
onClick={onBack}
|
|
className="flex items-center gap-2 text-zinc-500 dark:text-zinc-400 hover:text-zinc-900 dark:hover:text-white mb-4 transition-colors"
|
|
>
|
|
<ArrowLeft size={20} />
|
|
<span>Back</span>
|
|
</button>
|
|
|
|
<div className="flex flex-col md:flex-row md:items-start justify-between gap-4">
|
|
<div className="flex-1">
|
|
<h1 className="text-2xl md:text-3xl font-bold text-zinc-900 dark:text-white mb-2">{song.title}</h1>
|
|
<div className="flex items-center gap-3 mb-3">
|
|
<div
|
|
onClick={() => song.creator && onNavigateToProfile(song.creator)}
|
|
className="flex items-center gap-2 cursor-pointer hover:underline"
|
|
>
|
|
<div className="w-6 h-6 rounded-full bg-gradient-to-br from-indigo-500 to-purple-600 flex items-center justify-center text-xs font-bold text-white overflow-hidden">
|
|
{song.creator_avatar ? (
|
|
<img src={song.creator_avatar} alt={song.creator || 'Creator'} className="w-full h-full object-cover" />
|
|
) : (
|
|
song.creator ? song.creator[0].toUpperCase() : 'A'
|
|
)}
|
|
</div>
|
|
<span className="text-zinc-900 dark:text-white font-semibold">{song.creator || 'Anonymous'}</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Tags */}
|
|
<div className="flex flex-wrap gap-2 mb-2">
|
|
{song.style.split(',').slice(0, 4).map((tag, i) => (
|
|
<span key={i} className="px-2 py-1 bg-zinc-200 dark:bg-zinc-800 rounded text-xs text-zinc-600 dark:text-zinc-300">
|
|
{tag.trim()}
|
|
</span>
|
|
))}
|
|
</div>
|
|
|
|
<div className="text-xs text-zinc-500">
|
|
{new Date(song.createdAt).toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' })} at {new Date(song.createdAt).toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' })}
|
|
{!song.isPublic && song.userId === user?.id && (
|
|
<span className="ml-2 px-2 py-0.5 bg-zinc-200 dark:bg-zinc-800 rounded text-xs text-zinc-600 dark:text-zinc-400">Private</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Related Songs Tab - Hidden on mobile */}
|
|
<div className="hidden md:flex items-center gap-2">
|
|
<button className="px-4 py-2 bg-zinc-900 dark:bg-white text-white dark:text-black rounded-full text-sm font-semibold">
|
|
Similar
|
|
</button>
|
|
<button
|
|
onClick={() => song.creator && onNavigateToProfile(song.creator)}
|
|
className="px-4 py-2 text-zinc-500 dark:text-zinc-400 hover:text-zinc-900 dark:hover:text-white text-sm font-semibold transition-colors"
|
|
>
|
|
By {song.creator || 'Artist'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Content */}
|
|
<div className="flex-1 overflow-y-auto">
|
|
<div className="max-w-3xl mx-auto px-4 md:px-6 py-4 md:py-6">
|
|
|
|
{/* Left Column: Song Details */}
|
|
<div className="space-y-4 md:space-y-6">
|
|
{/* Cover Art */}
|
|
<div className="relative aspect-square max-w-xs md:max-w-sm mx-auto lg:mx-0 rounded-xl overflow-hidden shadow-2xl">
|
|
<img src={song.coverUrl} alt={song.title} className={`w-full h-full object-cover transition-transform duration-500 ${isCurrentlyPlaying ? 'scale-105' : ''}`} />
|
|
<button
|
|
onClick={() => onPlay(song)}
|
|
className={`absolute inset-0 transition-colors flex items-center justify-center group ${isCurrentSong ? 'bg-black/50' : 'bg-black/40 hover:bg-black/50'}`}
|
|
>
|
|
<div className="w-16 h-16 md:w-20 md:h-20 rounded-full bg-white group-hover:scale-110 transition-transform flex items-center justify-center shadow-xl">
|
|
{isCurrentlyPlaying ? (
|
|
<Pause size={28} className="text-black fill-black md:w-8 md:h-8" />
|
|
) : (
|
|
<Play size={28} className="text-black fill-black ml-1 md:w-8 md:h-8" />
|
|
)}
|
|
</div>
|
|
</button>
|
|
{isCurrentlyPlaying && (
|
|
<div className="absolute bottom-4 left-4 flex items-center gap-1">
|
|
<span className="w-1.5 h-4 bg-pink-500 rounded-full animate-pulse" style={{ animationDelay: '0ms' }} />
|
|
<span className="w-1.5 h-6 bg-pink-500 rounded-full animate-pulse" style={{ animationDelay: '150ms' }} />
|
|
<span className="w-1.5 h-3 bg-pink-500 rounded-full animate-pulse" style={{ animationDelay: '300ms' }} />
|
|
<span className="w-1.5 h-7 bg-pink-500 rounded-full animate-pulse" style={{ animationDelay: '450ms' }} />
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Action Buttons */}
|
|
<div className="flex items-center justify-center lg:justify-start gap-2 md:gap-3 flex-wrap">
|
|
<div className="flex items-center gap-2 bg-zinc-200 dark:bg-zinc-900 px-3 py-2 rounded-full text-sm">
|
|
<Eye size={16} className="text-zinc-600 dark:text-white" />
|
|
<span className="text-zinc-900 dark:text-white font-semibold">{song.viewCount || 0}</span>
|
|
</div>
|
|
<button
|
|
onClick={() => onToggleLike?.(song.id)}
|
|
className={`flex items-center gap-2 px-3 py-2 rounded-full text-sm transition-colors ${isLiked ? 'bg-pink-500 text-white' : 'bg-zinc-200 dark:bg-zinc-900 hover:bg-zinc-300 dark:hover:bg-zinc-800 text-zinc-900 dark:text-white'}`}
|
|
>
|
|
<Heart size={16} className={isLiked ? 'fill-current' : ''} />
|
|
<span className="font-semibold">{song.likeCount || 0}</span>
|
|
</button>
|
|
{user?.id === song.userId && (
|
|
<button
|
|
onClick={() => {
|
|
if (!song.audioUrl) return;
|
|
const audioUrl = song.audioUrl.startsWith('http') ? song.audioUrl : `${window.location.origin}${song.audioUrl}`;
|
|
window.open(`/editor?audioUrl=${encodeURIComponent(audioUrl)}`, '_blank');
|
|
}}
|
|
className="flex items-center gap-2 bg-indigo-600 hover:bg-indigo-700 px-3 py-2 rounded-full text-sm font-semibold transition-colors text-white"
|
|
>
|
|
<Edit3 size={16} />
|
|
<span className="hidden md:inline">Edit</span>
|
|
</button>
|
|
)}
|
|
<button
|
|
onClick={() => setShareModalOpen(true)}
|
|
className="p-2 bg-zinc-200 dark:bg-zinc-900 hover:bg-zinc-300 dark:hover:bg-zinc-800 rounded-full transition-colors"
|
|
>
|
|
<Share2 size={16} className="text-zinc-700 dark:text-white" />
|
|
</button>
|
|
<button className="p-2 bg-zinc-200 dark:bg-zinc-900 hover:bg-zinc-300 dark:hover:bg-zinc-800 rounded-full transition-colors">
|
|
<MoreHorizontal size={16} className="text-zinc-700 dark:text-white" />
|
|
</button>
|
|
</div>
|
|
|
|
{/* Lyrics */}
|
|
{song.lyrics && (
|
|
<div className="bg-white dark:bg-zinc-900/50 border border-zinc-200 dark:border-zinc-800 rounded-xl p-4">
|
|
<h3 className="text-sm font-semibold text-zinc-900 dark:text-white mb-3">Lyrics</h3>
|
|
<div className="text-sm text-zinc-700 dark:text-zinc-300 whitespace-pre-line leading-relaxed max-h-72 md:max-h-96 overflow-y-auto">
|
|
{song.lyrics}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
</div>
|
|
</div>
|
|
|
|
{song && (
|
|
<ShareModal
|
|
isOpen={shareModalOpen}
|
|
onClose={() => setShareModalOpen(false)}
|
|
song={song}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|