Update: Mobile UX, Show All, Infinite Scroll & New Categories

This commit is contained in:
Khoa.vo 2026-01-01 11:12:21 +07:00
parent c8c232afe9
commit 35876ee046
49 changed files with 80580 additions and 7584 deletions

7
.gitignore vendored
View file

@ -53,3 +53,10 @@ backend/data/*.json
!backend/data/data.json !backend/data/data.json
backend/cache/ backend/cache/
backend/data_seed/ backend/data_seed/
# Build Logs
*.log
build_log*.txt
# Backup Files
*_backup.*

View file

@ -30,8 +30,9 @@ COPY frontend/ .
# ENV NEXT_PUBLIC_API_URL="http://localhost:8000" Removed to use relative path proxying # ENV NEXT_PUBLIC_API_URL="http://localhost:8000" Removed to use relative path proxying
# Build Next.js # Build Next.js
ENV NEXTAUTH_URL=http://localhost:3000 ENV NEXTAUTH_URL=http://localhost:3000
# Generate a static secret for now to prevent 500 error, or use a build arg # Secret should be provided at runtime via docker run -e or docker-compose
ENV NEXTAUTH_SECRET=changeme_in_production_but_this_fixes_500_error ARG NEXTAUTH_SECRET_ARG=default_dev_secret_change_in_production
ENV NEXTAUTH_SECRET=${NEXTAUTH_SECRET_ARG}
RUN npm run build RUN npm run build
# --- Final Setup --- # --- Final Setup ---

View file

@ -129,6 +129,9 @@ You don't need to do anything manually to keep it updated! 🚀
- **Local-First**: Works offline (PWA) and syncs local playlists. - **Local-First**: Works offline (PWA) and syncs local playlists.
- **Smart Search**: Unified search across YouTube Music. - **Smart Search**: Unified search across YouTube Music.
- **Responsive**: Full mobile support with a dedicated full-screen player. - **Responsive**: Full mobile support with a dedicated full-screen player.
- **Smooth Loading**: Skeleton animations for seamless data fetching.
- **Infinite Experience**: "Show all" pages with infinite scrolling support.
- **Enhanced Mobile**: Optimized 3-column layouts and smart player visibility.
## 📝 License ## 📝 License
MIT License MIT License

View file

@ -14,6 +14,58 @@ router = APIRouter()
cache = CacheManager() cache = CacheManager()
playlist_manager = PlaylistManager() playlist_manager = PlaylistManager()
def get_high_res_thumbnail(thumbnails: list) -> str:
"""
Selects the best thumbnail and attempts to upgrade resolution
if it's a Google/YouTube URL.
"""
if not thumbnails:
return "https://placehold.co/300x300"
# 1. Start with the largest available in the list
best_url = thumbnails[-1]['url']
# 2. Upgrade resolution for Google User Content (lh3.googleusercontent.com, yt3.ggpht.com)
# Common patterns:
# =w120-h120-l90-rj (Small)
# =w544-h544-l90-rj (High Res)
# s120-c-k-c0x00ffffff-no-rj (Profile/Avatar)
if "googleusercontent.com" in best_url or "ggpht.com" in best_url:
import re
# Replace width/height params with 544 (standard YTM high res)
# We look for patterns like =w<num>-h<num>...
if "w" in best_url and "h" in best_url:
best_url = re.sub(r'=w\d+-h\d+', '=w544-h544', best_url)
elif best_url.startswith("https://lh3.googleusercontent.com") and "=" in best_url:
# Sometimes it's just URL=...
# We can try to force it
pass
return best_url
def extract_artist_names(track: dict) -> str:
"""Safely extracts artist names from track data (dict or str items)."""
artists = track.get('artists') or []
if isinstance(artists, list):
names = []
for a in artists:
if isinstance(a, dict):
names.append(a.get('name', 'Unknown'))
elif isinstance(a, str):
names.append(a)
return ", ".join(names) if names else "Unknown Artist"
return "Unknown Artist"
def extract_album_name(track: dict, default="Single") -> str:
"""Safely extracts album name from track data."""
album = track.get('album')
if isinstance(album, dict):
return album.get('name', default)
if isinstance(album, str):
return album
return default
def clean_text(text: str) -> str: def clean_text(text: str) -> str:
if not text: if not text:
return "" return ""
@ -92,6 +144,90 @@ async def get_browse_content():
print(f"Browse Error: {e}") print(f"Browse Error: {e}")
return [] return []
CATEGORIES_MAP = {
"Trending Vietnam": {"query": "Top 50 Vietnam", "type": "playlists"},
"Just released Songs": {"query": "New Released Songs", "type": "playlists"},
"Albums": {"query": "New Albums 2024", "type": "albums"},
"Vietnamese DJs": {"query": "Vinahouse Remix", "type": "playlists"},
"Global Hits": {"query": "Global Top 50", "type": "playlists"},
"Chill Vibes": {"query": "Chill Lofi", "type": "playlists"},
"Party Time": {"query": "Party EDM Hits", "type": "playlists"},
"Best of Ballad": {"query": "Vietnamese Ballad", "type": "playlists"},
"Hip Hop & Rap": {"query": "Vietnamese Rap", "type": "playlists"},
}
@router.get("/browse/category")
async def get_browse_category(name: str):
"""
Fetch live data for a specific category (infinite scroll support).
Fetches up to 50-100 items.
"""
if name not in CATEGORIES_MAP:
raise HTTPException(status_code=404, detail="Category not found")
info = CATEGORIES_MAP[name]
query = info["query"]
search_type = info["type"]
# Check Cache
cache_key = f"browse_category:{name}"
cached = cache.get(cache_key)
if cached:
return cached
try:
from ytmusicapi import YTMusic
yt = YTMusic()
# Search for more items (e.g. 50)
results = yt.search(query, filter=search_type, limit=50)
category_items = []
for result in results:
item_id = result.get('browseId')
if not item_id: continue
title = result.get('title', 'Unknown')
# Simple item structure for list view (we don't need full track list for every item immediately)
# But frontend expects some structure.
# Extract basic thumbnails
thumbnails = result.get('thumbnails', [])
cover_url = get_high_res_thumbnail(thumbnails)
# description logic
description = ""
if search_type == "albums":
artists_text = ", ".join([a.get('name') for a in result.get('artists', [])])
year = result.get('year', '')
description = f"Album by {artists_text}{year}"
is_album = True
else:
is_album = False
# For playlists result, description might be missing in search result
description = f"Playlist • {result.get('itemCount', '')} tracks"
category_items.append({
"id": item_id,
"title": title,
"description": description,
"cover_url": cover_url,
"type": "album" if is_album else "playlist",
# Note: We are NOT fetching full tracks for each item here to save speed/quota.
# The frontend only needs cover, title, description, id.
# Tracks are fetched when user clicks the item (via get_playlist).
"tracks": []
})
cache.set(cache_key, category_items, ttl_seconds=3600) # Cache for 1 hour
return category_items
except Exception as e:
print(f"Category Fetch Error: {e}")
return []
@router.get("/playlists") @router.get("/playlists")
async def get_user_playlists(): async def get_user_playlists():
return playlist_manager.get_all() return playlist_manager.get_all()
@ -134,12 +270,12 @@ async def get_playlist(id: str):
playlist_data = None playlist_data = None
is_album = False is_album = False
# Try as Album first if ID looks like an album (MPREb...) or just try block
if id.startswith("MPREb"): if id.startswith("MPREb"):
try: try:
playlist_data = yt.get_album(id) playlist_data = yt.get_album(id)
is_album = True is_album = True
except: except Exception as e:
print(f"DEBUG: get_album(1) failed: {e}")
pass pass
if not playlist_data: if not playlist_data:
@ -147,24 +283,28 @@ async def get_playlist(id: str):
# ytmusicapi returns a dict with 'tracks' list # ytmusicapi returns a dict with 'tracks' list
playlist_data = yt.get_playlist(id, limit=100) playlist_data = yt.get_playlist(id, limit=100)
except Exception as e: except Exception as e:
print(f"DEBUG: get_playlist failed: {e}")
import traceback, sys
traceback.print_exc(file=sys.stdout)
# Fallback: Try as album if not tried yet # Fallback: Try as album if not tried yet
if not is_album: if not is_album:
try: try:
playlist_data = yt.get_album(id) playlist_data = yt.get_album(id)
is_album = True is_album = True
except: except Exception as e2:
print(f"DEBUG: get_album(2) failed: {e2}")
traceback.print_exc(file=sys.stdout)
raise e # Re-raise if both fail raise e # Re-raise if both fail
if not isinstance(playlist_data, dict):
print(f"DEBUG: Validation Failed! playlist_data type: {type(playlist_data)}", flush=True)
raise ValueError(f"Invalid playlist_data: {playlist_data}")
# Format to match our app's Protocol # Format to match our app's Protocol
formatted_tracks = [] formatted_tracks = []
if 'tracks' in playlist_data: if 'tracks' in playlist_data:
for track in playlist_data['tracks']: for track in playlist_data['tracks']:
# Safely extract artists artist_names = extract_artist_names(track)
artists_list = track.get('artists') or []
if isinstance(artists_list, list):
artist_names = ", ".join([a.get('name', 'Unknown') for a in artists_list])
else:
artist_names = "Unknown Artist"
# Safely extract thumbnails # Safely extract thumbnails
thumbnails = track.get('thumbnails', []) thumbnails = track.get('thumbnails', [])
@ -172,12 +312,10 @@ async def get_playlist(id: str):
# Albums sometimes have thumbnails at root level, not per track # Albums sometimes have thumbnails at root level, not per track
thumbnails = playlist_data.get('thumbnails', []) thumbnails = playlist_data.get('thumbnails', [])
cover_url = thumbnails[-1]['url'] if thumbnails else "https://placehold.co/300x300" cover_url = get_high_res_thumbnail(thumbnails)
# Safely extract album # Safely extract album
album_info = track.get('album') album_name = extract_album_name(track, playlist_data.get('title', 'Single'))
# If it's an album fetch, the album name is the playlist title
album_name = album_info.get('name', playlist_data.get('title')) if album_info else playlist_data.get('title', 'Single')
formatted_tracks.append({ formatted_tracks.append({
"title": track.get('title', 'Unknown Title'), "title": track.get('title', 'Unknown Title'),
@ -191,13 +329,29 @@ async def get_playlist(id: str):
# Get Playlist Cover (usually highest res) # Get Playlist Cover (usually highest res)
thumbnails = playlist_data.get('thumbnails', []) thumbnails = playlist_data.get('thumbnails', [])
p_cover = thumbnails[-1]['url'] if thumbnails else "https://placehold.co/300x300" p_cover = get_high_res_thumbnail(thumbnails)
# Safely extract author/artists
author = "YouTube Music"
if is_album:
artists = playlist_data.get('artists', [])
names = []
for a in artists:
if isinstance(a, dict): names.append(a.get('name', 'Unknown'))
elif isinstance(a, str): names.append(a)
author = ", ".join(names)
else:
author_data = playlist_data.get('author', {})
if isinstance(author_data, dict):
author = author_data.get('name', 'YouTube Music')
else:
author = str(author_data)
formatted_playlist = { formatted_playlist = {
"id": playlist_data.get('browseId', playlist_data.get('id')), "id": playlist_data.get('browseId', playlist_data.get('id')),
"title": clean_title(playlist_data.get('title', 'Unknown')), "title": clean_title(playlist_data.get('title', 'Unknown')),
"description": clean_description(playlist_data.get('description', '')), "description": clean_description(playlist_data.get('description', '')),
"author": playlist_data.get('author', {}).get('name', 'YouTube Music') if not is_album else ", ".join([a.get('name','') for a in playlist_data.get('artists', [])]), "author": author,
"cover_url": p_cover, "cover_url": p_cover,
"tracks": formatted_tracks "tracks": formatted_tracks
} }
@ -207,7 +361,15 @@ async def get_playlist(id: str):
return formatted_playlist return formatted_playlist
except Exception as e: except Exception as e:
print(f"Playlist Fetch Error: {e}") import traceback
print(f"Playlist Fetch Error (NEW CODE): {e}", flush=True)
print(traceback.format_exc(), flush=True)
try:
print(f"Playlist Data Type: {type(playlist_data)}")
if 'tracks' in playlist_data and playlist_data['tracks']:
print(f"First Track Type: {type(playlist_data['tracks'][0])}")
except:
pass
raise HTTPException(status_code=404, detail="Playlist not found") raise HTTPException(status_code=404, detail="Playlist not found")
class UpdatePlaylistRequest(BaseModel): class UpdatePlaylistRequest(BaseModel):
@ -261,20 +423,13 @@ async def search_tracks(query: str):
tracks = [] tracks = []
for track in results: for track in results:
# Safely extract artists artist_names = extract_artist_names(track)
artists_list = track.get('artists') or []
if isinstance(artists_list, list):
artist_names = ", ".join([a.get('name', 'Unknown') for a in artists_list])
else:
artist_names = "Unknown Artist"
# Safely extract thumbnails # Safely extract thumbnails
thumbnails = track.get('thumbnails', []) thumbnails = track.get('thumbnails', [])
cover_url = thumbnails[-1]['url'] if thumbnails else "https://placehold.co/300x300" cover_url = get_high_res_thumbnail(thumbnails)
# Safely extract album album_name = extract_album_name(track, "Single")
album_info = track.get('album')
album_name = album_info.get('name', 'Single') if album_info else "Single"
tracks.append({ tracks.append({
"title": track.get('title', 'Unknown Title'), "title": track.get('title', 'Unknown Title'),
@ -319,23 +474,21 @@ async def get_recommendations(seed_id: str = None):
tracks = [] tracks = []
if 'tracks' in watch_playlist: if 'tracks' in watch_playlist:
seen_ids = set()
seen_ids.add(seed_id)
for track in watch_playlist['tracks']: for track in watch_playlist['tracks']:
# Skip the seed track itself if play history already has it # Skip if seen or seed
if track.get('videoId') == seed_id: t_id = track.get('videoId')
if not t_id or t_id in seen_ids:
continue continue
seen_ids.add(t_id)
artists_list = track.get('artists') or [] artist_names = extract_artist_names(track)
if isinstance(artists_list, list):
artist_names = ", ".join([a.get('name', 'Unknown') for a in artists_list])
else:
artist_names = "Unknown Artist"
thumbnails = track.get('thumbnails', []) thumbnails = track.get('thumbnails') or track.get('thumbnail') or []
cover_url = thumbnails[-1]['url'] if thumbnails else "https://placehold.co/300x300" cover_url = get_high_res_thumbnail(thumbnails)
# album is often missing in watch playlist, fallback album_name = extract_album_name(track, "Single")
album_info = track.get('album')
album_name = album_info.get('name', 'Single') if album_info else "Single"
tracks.append({ tracks.append({
"title": track.get('title', 'Unknown Title'), "title": track.get('title', 'Unknown Title'),
@ -343,8 +496,8 @@ async def get_recommendations(seed_id: str = None):
"album": album_name, "album": album_name,
"duration": track.get('length_seconds', track.get('duration_seconds', 0)), "duration": track.get('length_seconds', track.get('duration_seconds', 0)),
"cover_url": cover_url, "cover_url": cover_url,
"id": track.get('videoId'), "id": t_id,
"url": f"https://music.youtube.com/watch?v={track.get('videoId')}" "url": f"https://music.youtube.com/watch?v={t_id}"
}) })
response_data = {"tracks": tracks} response_data = {"tracks": tracks}
@ -379,7 +532,7 @@ async def get_recommended_albums(seed_artist: str = None):
albums = [] albums = []
for album in results: for album in results:
thumbnails = album.get('thumbnails', []) thumbnails = album.get('thumbnails', [])
cover_url = thumbnails[-1]['url'] if thumbnails else "https://placehold.co/300x300" cover_url = get_high_res_thumbnail(thumbnails)
albums.append({ albums.append({
"title": album.get('title', 'Unknown Album'), "title": album.get('title', 'Unknown Album'),
@ -396,6 +549,38 @@ async def get_recommended_albums(seed_artist: str = None):
print(f"Album Rec Error: {e}") print(f"Album Rec Error: {e}")
return [] return []
@router.get("/artist/info")
async def get_artist_info(name: str):
"""
Get artist metadata (photo) by name.
"""
if not name:
return {"photo": None}
cache_key = f"artist_info:{name.lower().strip()}"
cached = cache.get(cache_key)
if cached:
return cached
try:
from ytmusicapi import YTMusic
yt = YTMusic()
results = yt.search(name, filter="artists", limit=1)
if results:
artist = results[0]
thumbnails = artist.get('thumbnails', [])
photo_url = get_high_res_thumbnail(thumbnails)
result = {"photo": photo_url}
cache.set(cache_key, result, ttl_seconds=86400 * 7) # Cache for 1 week
return result
return {"photo": None}
except Exception as e:
print(f"Artist Info Error: {e}")
return {"photo": None}
@router.get("/trending") @router.get("/trending")
async def get_trending(): async def get_trending():
""" """

View file

@ -1,548 +1,449 @@
{ {
"id": "VLPLpY7hx7jry7zc4zspi_fBhWQt8z5jrJ8z", "id": "VLPLm_fM7dlkg8FbRVCCosRFtDldS74OVSgi",
"title": "Best Vietnamese Songs 2026 - Popular Vietnamese Songs 2026 Playlist (Top Vietnamese Music 2026-2027)", "title": "\ud83d\udcbf Vietnam \ud83c\udfa7 TOP 50 \ud83d\udcbf Music Chart \ud83c\udfa7 Full SPOTIFY Video Playlist \ud83d\udcbf Updated Weekly \ud83c\udfa7",
"description": "Best Vietnamese Songs 2026 - Popular Vietnamese Songs 2026 Playlist (Top Vietnamese Music 2026-2027)\n\nIf you liked this playlist, we recommend you also listen to these music lists:\n\n1. Most Popular Vietnamese Songs 2026 - Best of Vietnamese Music 2026 Playlist (Famous Vietnamese Songs 2026-2027) - https://goplaylists.com/56624\n2. New Vietnamese Songs 2026 - Latest Vietnamese Song 2026 Playlist (New Vietnam Music 2026-2027) - https://goplaylists.com/13081\n\nFind our playlist with these keywords: popular vietnamese songs 2026, top vietnamese songs 2026, best vietnamese music 2026, vietnam music playlist, top vietnamese music 2026, vietnam playlist 2026, vietnamese songs 2026, popular, vietnamese songs, vietnam playlist music, best vietnamese songs 2026, vietnamese playlist 2026, vietnamese hits 2026, vietnamese songs, top vietnam music 2026, vietnam song playlist, top 10 vietnamese songs, vietnam music chart 2026, vietnamese song trends\n\nDive deep into the mesmerizing world of Vietnamese music with a curated collection of the year's most compelling tracks. Experience the rhythm, the emotion, and the soulful voices of Vietnam's top artists. Each song has been handpicked to represent the heartbeat of Vietnam's contemporary music scene, bringing to you an array of melodies that resonate with the beauty and culture of this enchanting nation. Whether you're a longtime fan or a newcomer to Vietnamese tunes, this selection is bound to captivate your senses and take you on an unforgettable musical journey \ud83c\udfb5.\n\nIn the next year, the playlist is going to be titled: Best Vietnamese Songs 2027 - Popular Vietnamese Songs 2027 Playlist (Top Vietnamese Music 2027-2028)\n Last year, the playlist was titled: Best Vietnamese Songs 2025 - Popular Vietnamese Songs 2025 Playlist (Top Vietnamese Music 2025-2026)\n\nShare your thoughts on our playlist: contact@red-music.com", "description": "THE * VIRAL 50 * https://bit.ly/3SH4lrf\n*****************************************\n*****************************************\nIf u like our playlist, please save it. \nThis playlist is updated weekly, so stay with us for more new hit songs...\n\n*****************************************\n************ 1 HOUR LOOPS ************\nRocket-Media * 1 HOUR LOOPS: https://bit.ly/39T7uj9\nRocket-Media * 1 HOUR LOOPS * HITS: https://bit.ly/3dFFJvw\nRocket-Media * 1 HOUR LOOPS * DANCE: https://bit.ly/3urhN5M\nRocket-Media * 1 HOUR LOOPS * ROCKS: https://bit.ly/3wF8L76\nRocket-Media * 1 HOUR LOOPS * CLASSIC: https://bit.ly/3t375lH\n\n*****************************************\n************** PLAYLISTS **************\nFavorite brobeat playlist: https://www.youtube.com/playlist?list...\nDeezer Playlist: https://www.deezer.com/en/profile/533...\nRocket-Media MTV Dance: https://www.youtube.com/playlist?list...\nRocket-Media MTV Hits: https://www.youtube.com/playlist?list...\nRocket-Media MTV Rocks: https://www.youtube.com/playlist?list...\nRocket-Media MTV Classic: https://www.youtube.com/playlist?list...\nRocket-Media Concerts UltraHD 4K: https://www.youtube.com/playlist?list...\nRocket-Media Mixes UltraHD 4K: https://www.youtube.com/playlist?list...\n\n*****************************************\n*****************************************\n\n************** YOUTUBE **************\n*** Central * Rocket-Media * Central ***\nhttps://www.youtube.com/channel/UCkN5...\n************** YOUTUBE **************\n*****************************************\n01. brobeat: https://www.youtube.com/channel/UC8_2...\n02. remixit: https://www.youtube.com/channel/UCvY5...\n03. remixit * greek: https://www.youtube.com/channel/UC0nw...\n04. Liverpool F.C. Legends: https://www.youtube.com/channel/UCq0J...\n05. Juventus F.C. Legends: https://www.youtube.com/channel/UC6JR...\n06. Chicago Bulls Legends: https://www.youtube.com/channel/UC9hs...\n07. Boston Celtics Legends: https://www.youtube.com/channel/UC83o...\n08. Miami Heat Legends: https://www.youtube.com/channel/UCSrh...\n09. Trail Blazers Legends: https://www.youtube.com/channel/UCw_2...\n10. OKC Thunder Legends: https://www.youtube.com/channel/UCa2y...\n11. Rockets Legends: https://www.youtube.com/channel/UCK8U...\n\n*****************************************\n*****************************************\n\n*** Central * Rocket-Media * Central ***\nhttps://www.youtube.com/channel/UCkN5...\n*****************************************\nViral Instagram Hashtags: https://www.instagram.com/goviralhash...\n*****************************************\nFavorite brobeat playlist: https://www.youtube.com/playlist?list...\nDeezer Playlist: https://www.deezer.com/en/profile/533...\n************** FACEBOOK **************\n01. https://www.facebook.com/rocketmediaw...\n02. https://www.facebook.com/TheViralRock...\n************* INSTAGRAM *************\n01. https://www.instagram.com/brobeat72/\n02. https://www.instagram.com/remixit72/\n*************** TWITTER ***************\n01. https://twitter.com/brobeat72\n02. https://twitter.com/remixit72\n************** PINTEREST **************\nhttps://pinterest.com/brobeat72/\n*************** TUMBLR ***************\nhttps://rocketmedia72.tumblr.com/\n\n*****************************************\n*****************************************\n\ntop vietnamese songs\nvietnam music charts\nvietnam music hits\nvietnam top music charts\nvietnamese music channel\nvietnamese music charts\n2012 chart music\n4music charts\namazon world music chart\nasian music charts\nbest chart music\nbest international music\nbest music usa\nbest music world\nbest songs charts\nbest songs us charts\nbest world music songs\nbiggest music singles\nbillboard charts worldwide\nbillboard world album\nbillboard world album charts\nbillboard world charts\nbillboard world music\nbillboard world music album chart\nbillboard world music chart internet charts\nceltic thunder billboard world music chart\nchart list music\nchart mtv music\nchart music 2014\nchart music chart music\nchart music free online\nchart official music\nchart official us music\nchart song usa\nchart songs\nchartmusic\ncharts all\ncharts all over\ncharts around the world\ncharts around world\ncharts over world\ncharts usa\nchina chart music\nchinese chart music\nclassical music charts\nenglish music chart\nenglish music top chart",
"cover_url": "https://yt3.googleusercontent.com/JaOpEjRt9S4wYkuVMkbu_2NLadP4vtUfQIpUlfob8mgB3CuoX8AsAJ24tAtbNRXGD2AkekLlbkiU=s1200", "cover_url": "https://yt3.googleusercontent.com/eEjGg43BajytnnS5S0gBc_rhXXhFLBLU-e8QF4jNMxX4W2oqmBhdd5uBzuTu11X5bRqpcX2hHw4=s1200",
"tracks": [ "tracks": [
{ {
"title": "Kh\u00f3a Ly Bi\u1ec7t (feat. Anh T\u00fa)", "title": "Who",
"artist": "The Masked Singer", "artist": "Jimin",
"album": "Best Vietnamese Songs 2026 - Popular Vietnamese Songs 2026 Playlist (Top Vietnamese Music 2026-2027)", "album": "\ud83d\udcbf Vietnam \ud83c\udfa7 TOP 50 \ud83d\udcbf Music Chart \ud83c\udfa7 Full SPOTIFY Video Playlist \ud83d\udcbf Updated Weekly \ud83c\udfa7",
"duration": 327,
"cover_url": "https://i.ytimg.com/vi/wEPX3V5T63M/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3kG4sf2WSe1cO3TUeSG4cyGdPXJFg",
"id": "wEPX3V5T63M",
"url": "https://music.youtube.com/watch?v=wEPX3V5T63M"
},
{
"title": "T\u1eebng Ng\u00e0y Y\u00eau Em",
"artist": "buitruonglinh",
"album": "Best Vietnamese Songs 2026 - Popular Vietnamese Songs 2026 Playlist (Top Vietnamese Music 2026-2027)",
"duration": 222,
"cover_url": "https://i.ytimg.com/vi/f-VsoLm4i5c/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3loMnKS_mCSDRyYu9wS_TYnA0NAgQ",
"id": "f-VsoLm4i5c",
"url": "https://music.youtube.com/watch?v=f-VsoLm4i5c"
},
{
"title": "M\u1ea5t K\u1ebft N\u1ed1i",
"artist": "D\u01b0\u01a1ng Domic",
"album": "Best Vietnamese Songs 2026 - Popular Vietnamese Songs 2026 Playlist (Top Vietnamese Music 2026-2027)",
"duration": 208, "duration": 208,
"cover_url": "https://i.ytimg.com/vi/lRsaDQtYqAo/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3mvidflzqRlL9xdJeDUXZJg_UESRw", "cover_url": "https://i.ytimg.com/vi/Av9DvtlJ9_M/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3lo74Sku93WaUE-8yH-iP6Zw_M7Uw",
"id": "lRsaDQtYqAo", "id": "Av9DvtlJ9_M",
"url": "https://music.youtube.com/watch?v=lRsaDQtYqAo" "url": "https://music.youtube.com/watch?v=Av9DvtlJ9_M"
}, },
{ {
"title": "m\u1ed9t \u0111\u1eddi (feat. buitruonglinh)", "title": "Seven (feat. Latto)",
"artist": "Bon Nghi\u00eam, 14 Casper", "artist": "Jung Kook",
"album": "Best Vietnamese Songs 2026 - Popular Vietnamese Songs 2026 Playlist (Top Vietnamese Music 2026-2027)", "album": "\ud83d\udcbf Vietnam \ud83c\udfa7 TOP 50 \ud83d\udcbf Music Chart \ud83c\udfa7 Full SPOTIFY Video Playlist \ud83d\udcbf Updated Weekly \ud83c\udfa7",
"duration": 329, "duration": 227,
"cover_url": "https://i.ytimg.com/vi/JgTZvDbaTtg/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3lEKS8TNud8_GWknaWc0IQEQWBTgw", "cover_url": "https://i.ytimg.com/vi/QU9c0053UAU/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3ki23gt683kPaAs_tipdild1wrLTQ",
"id": "JgTZvDbaTtg", "id": "QU9c0053UAU",
"url": "https://music.youtube.com/watch?v=JgTZvDbaTtg" "url": "https://music.youtube.com/watch?v=QU9c0053UAU"
}, },
{ {
"title": "V\u00f9ng An To\u00e0n", "title": "M\u1ed8NG YU - AMEE x MCK | Official Music Video (from \u2018M\u1ed8NGMEE\u2019 album)",
"artist": "V#, B Ray", "artist": "AMEE",
"album": "Best Vietnamese Songs 2026 - Popular Vietnamese Songs 2026 Playlist (Top Vietnamese Music 2026-2027)", "album": "\ud83d\udcbf Vietnam \ud83c\udfa7 TOP 50 \ud83d\udcbf Music Chart \ud83c\udfa7 Full SPOTIFY Video Playlist \ud83d\udcbf Updated Weekly \ud83c\udfa7",
"duration": 266, "duration": 284,
"cover_url": "https://i.ytimg.com/vi/_XX248bq6Pw/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3nKfVzeukJ8dCNJ_hzcyZAsvJ8upg", "cover_url": "https://i.ytimg.com/vi/09Mh7GgUFFA/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3kOfLMsTFC8tE0kNUwRjBLFPok5BQ",
"id": "_XX248bq6Pw", "id": "09Mh7GgUFFA",
"url": "https://music.youtube.com/watch?v=_XX248bq6Pw" "url": "https://music.youtube.com/watch?v=09Mh7GgUFFA"
}, },
{ {
"title": "Qu\u00e2n A.P | C\u00f3 Ai H\u1eb9n H\u00f2 C\u00f9ng Em Ch\u01b0a", "title": "Die With A Smile",
"artist": "Qu\u00e2n A.P", "artist": "Lady Gaga, Bruno Mars",
"album": "Best Vietnamese Songs 2026 - Popular Vietnamese Songs 2026 Playlist (Top Vietnamese Music 2026-2027)", "album": "\ud83d\udcbf Vietnam \ud83c\udfa7 TOP 50 \ud83d\udcbf Music Chart \ud83c\udfa7 Full SPOTIFY Video Playlist \ud83d\udcbf Updated Weekly \ud83c\udfa7",
"duration": 319, "duration": 253,
"cover_url": "https://i.ytimg.com/vi/zHDECJy0p7k/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3nWDqcf0SVJtIipbWQqltt3cNu6eQ", "cover_url": "https://i.ytimg.com/vi/kPa7bsKwL-c/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3m0dtEFyfTFfbWA9Qj84zDLREtdUw",
"id": "zHDECJy0p7k", "id": "kPa7bsKwL-c",
"url": "https://music.youtube.com/watch?v=zHDECJy0p7k" "url": "https://music.youtube.com/watch?v=kPa7bsKwL-c"
}, },
{ {
"title": "b\u00ecnh y\u00ean - V\u0169. (feat. Binz)", "title": "Standing Next to You",
"artist": "V\u0169., Binz", "artist": "Jung Kook",
"album": "Best Vietnamese Songs 2026 - Popular Vietnamese Songs 2026 Playlist (Top Vietnamese Music 2026-2027)", "album": "\ud83d\udcbf Vietnam \ud83c\udfa7 TOP 50 \ud83d\udcbf Music Chart \ud83c\udfa7 Full SPOTIFY Video Playlist \ud83d\udcbf Updated Weekly \ud83c\udfa7",
"duration": 203, "duration": 227,
"cover_url": "https://i.ytimg.com/vi/f9P7_qWrf38/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3kI5gsa8Jegzzu2vFpJBhLk58mGeg", "cover_url": "https://i.ytimg.com/vi/UNo0TG9LwwI/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3myS1MPOlFVYv7S9qH2lm9wln3RlQ",
"id": "f9P7_qWrf38", "id": "UNo0TG9LwwI",
"url": "https://music.youtube.com/watch?v=f9P7_qWrf38" "url": "https://music.youtube.com/watch?v=UNo0TG9LwwI"
}, },
{ {
"title": "n\u1ebfu l\u00fac \u0111\u00f3 (feat. 2pillz)", "title": "REGRET - LYRICS",
"artist": "Tlinh", "artist": "VieShows",
"album": "Best Vietnamese Songs 2026 - Popular Vietnamese Songs 2026 Playlist (Top Vietnamese Music 2026-2027)", "album": "\ud83d\udcbf Vietnam \ud83c\udfa7 TOP 50 \ud83d\udcbf Music Chart \ud83c\udfa7 Full SPOTIFY Video Playlist \ud83d\udcbf Updated Weekly \ud83c\udfa7",
"duration": 325, "duration": 541,
"cover_url": "https://i.ytimg.com/vi/fyMgBQioTLo/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3kNXGGAK5wy2ix4mQ1pNwlGLYUg0Q", "cover_url": "https://i.ytimg.com/vi/LWzxnYB8K08/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3m_mMidsikFjBFNkEKN7eK6Bl76iA",
"id": "fyMgBQioTLo", "id": "LWzxnYB8K08",
"url": "https://music.youtube.com/watch?v=fyMgBQioTLo" "url": "https://music.youtube.com/watch?v=LWzxnYB8K08"
},
{
"title": "NG\u00c1O NG\u01a0- LYRICS | ANH TRAI SAY HI",
"artist": "VieShows",
"album": "\ud83d\udcbf Vietnam \ud83c\udfa7 TOP 50 \ud83d\udcbf Music Chart \ud83c\udfa7 Full SPOTIFY Video Playlist \ud83d\udcbf Updated Weekly \ud83c\udfa7",
"duration": 541,
"cover_url": "https://i.ytimg.com/vi/LvWPPjJE-uE/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3kSDz3hkRb9VfKFGPxHHUaR9bKzOA",
"id": "LvWPPjJE-uE",
"url": "https://music.youtube.com/watch?v=LvWPPjJE-uE"
}, },
{ {
"title": "\u0110\u1eebng L\u00e0m Tr\u00e1i Tim Anh \u0110au", "title": "\u0110\u1eebng L\u00e0m Tr\u00e1i Tim Anh \u0110au",
"artist": "S\u01a1n T\u00f9ng M-TP", "artist": "S\u01a1n T\u00f9ng M-TP",
"album": "Best Vietnamese Songs 2026 - Popular Vietnamese Songs 2026 Playlist (Top Vietnamese Music 2026-2027)", "album": "\ud83d\udcbf Vietnam \ud83c\udfa7 TOP 50 \ud83d\udcbf Music Chart \ud83c\udfa7 Full SPOTIFY Video Playlist \ud83d\udcbf Updated Weekly \ud83c\udfa7",
"duration": 326, "duration": 326,
"cover_url": "https://i.ytimg.com/vi/abPmZCZZrFA/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3nMzmdGlrfqmf8o9z-E5waTnqFXxA", "cover_url": "https://i.ytimg.com/vi/abPmZCZZrFA/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3nMzmdGlrfqmf8o9z-E5waTnqFXxA",
"id": "abPmZCZZrFA", "id": "abPmZCZZrFA",
"url": "https://music.youtube.com/watch?v=abPmZCZZrFA" "url": "https://music.youtube.com/watch?v=abPmZCZZrFA"
}, },
{ {
"title": "N\u1ed7i \u0110au Gi\u1eefa H\u00f2a B\u00ecnh", "title": "H\u00c0O QUANG (feat. RHYDER, D\u01af\u01a0NG DOMIC & PH\u00c1P KI\u1ec0U)",
"artist": "H\u00f2a Minzy, Nguyen Van Chung", "artist": "ANH TRAI \"SAY HI\"",
"album": "Best Vietnamese Songs 2026 - Popular Vietnamese Songs 2026 Playlist (Top Vietnamese Music 2026-2027)", "album": "T\u1eacP 5 - ANH TRAI \"SAY HI\"",
"duration": 454, "duration": 246,
"cover_url": "https://i.ytimg.com/vi/yHikkFeIHNA/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3mhpsSG0tDGSBKkAK1X81aY1nrfgg", "cover_url": "https://lh3.googleusercontent.com/peH3Ubcoqxirb5EQxA-E0DkZAmQGZX5AiDpBA3Ow6sFUhNcfIAOLJbMzqpL8lGNBAFvEYoeD5xBt-lk=w120-h120-l90-rj",
"id": "yHikkFeIHNA", "id": "TMUWRIau4PU",
"url": "https://music.youtube.com/watch?v=yHikkFeIHNA" "url": "https://music.youtube.com/watch?v=TMUWRIau4PU"
}, },
{ {
"title": "10 Shots", "title": "Love Me Again",
"artist": "Dax", "artist": "V",
"album": "Best Vietnamese Songs 2026 - Popular Vietnamese Songs 2026 Playlist (Top Vietnamese Music 2026-2027)", "album": "\ud83d\udcbf Vietnam \ud83c\udfa7 TOP 50 \ud83d\udcbf Music Chart \ud83c\udfa7 Full SPOTIFY Video Playlist \ud83d\udcbf Updated Weekly \ud83c\udfa7",
"duration": 233, "duration": 198,
"cover_url": "https://i.ytimg.com/vi/0XMFwdfMQmQ/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3napt1cPSL4BTo7SSeDyrRUU7XF0Q", "cover_url": "https://i.ytimg.com/vi/HYzyRHAHJl8/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3nc6Ad1QlMZEkDRZyM8qr4euI4KtQ",
"id": "0XMFwdfMQmQ", "id": "HYzyRHAHJl8",
"url": "https://music.youtube.com/watch?v=0XMFwdfMQmQ" "url": "https://music.youtube.com/watch?v=HYzyRHAHJl8"
}, },
{ {
"title": "Ch\u0103m Hoa", "title": "2 4 - w/n (3107 - 2024) (LYRICS)",
"artist": "MONO", "artist": "Nghe nh\u1ea1c Official",
"album": "Best Vietnamese Songs 2026 - Popular Vietnamese Songs 2026 Playlist (Top Vietnamese Music 2026-2027)", "album": "\ud83d\udcbf Vietnam \ud83c\udfa7 TOP 50 \ud83d\udcbf Music Chart \ud83c\udfa7 Full SPOTIFY Video Playlist \ud83d\udcbf Updated Weekly \ud83c\udfa7",
"duration": 260, "duration": 194,
"cover_url": "https://i.ytimg.com/vi/WCm2elbTEZQ/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3kW5xCqL0V0Q9miffXVKmSRnn3S8A", "cover_url": "https://i.ytimg.com/vi/M7KlePwgtE0/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3mR1KhgL-_ug9euybHCS0MFHfuuLA",
"id": "WCm2elbTEZQ", "id": "M7KlePwgtE0",
"url": "https://music.youtube.com/watch?v=WCm2elbTEZQ" "url": "https://music.youtube.com/watch?v=M7KlePwgtE0"
},
{
"title": "Sau C\u01a1n M\u01b0a",
"artist": "COOLKID , RHYDER",
"album": "\ud83d\udcbf Vietnam \ud83c\udfa7 TOP 50 \ud83d\udcbf Music Chart \ud83c\udfa7 Full SPOTIFY Video Playlist \ud83d\udcbf Updated Weekly \ud83c\udfa7",
"duration": 154,
"cover_url": "https://i.ytimg.com/vi/iFoLKvdqXk8/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3lWsLcVGvFYQwQYKj0QNo-6ZE-19g",
"id": "iFoLKvdqXk8",
"url": "https://music.youtube.com/watch?v=iFoLKvdqXk8"
},
{
"title": "Exit Sign",
"artist": "HIEUTHUHAI",
"album": "\ud83d\udcbf Vietnam \ud83c\udfa7 TOP 50 \ud83d\udcbf Music Chart \ud83c\udfa7 Full SPOTIFY Video Playlist \ud83d\udcbf Updated Weekly \ud83c\udfa7",
"duration": 202,
"cover_url": "https://i.ytimg.com/vi/sJt_i0hOugA/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3lcKx6N9oT-jvflhlil6p0IMK4WQA",
"id": "sJt_i0hOugA",
"url": "https://music.youtube.com/watch?v=sJt_i0hOugA"
},
{
"title": "I'M THINKING ABOUT YOU (feat. RHYDER, WEAN, \u0110\u1ee8C PH\u00daC & H\u00d9NG HU\u1ef2NH)",
"artist": "ANH TRAI \"SAY HI\"",
"album": "T\u1eacP 8 - ANH TRAI \"SAY HI\"",
"duration": 279,
"cover_url": "https://lh3.googleusercontent.com/248xlXUpIjHgrxGAJwcVxUnRobbqWPo2kbO7byupciekLxOE3ZfL854mWNqB1Bq_aGLwp6hASXknS-Cc=w120-h120-l90-rj",
"id": "C2d6C89Erb8",
"url": "https://music.youtube.com/watch?v=C2d6C89Erb8"
},
{
"title": "Cu\u1ed9c g\u1ecdi l\u00fac n\u1eeda \u0111\u00eam",
"artist": "AMEE",
"album": "\ud83d\udcbf Vietnam \ud83c\udfa7 TOP 50 \ud83d\udcbf Music Chart \ud83c\udfa7 Full SPOTIFY Video Playlist \ud83d\udcbf Updated Weekly \ud83c\udfa7",
"duration": 183,
"cover_url": "https://i.ytimg.com/vi/D64So_vDEZI/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3nuD-dfRwbfHx70IqrX06Hxwojg3g",
"id": "D64So_vDEZI",
"url": "https://music.youtube.com/watch?v=D64So_vDEZI"
},
{
"title": "CH\u00c2N TH\u00c0NH (feat. RHYDER, CAPTAIN, QUANG H\u00d9NG MASTERD & WEAN)",
"artist": "ANH TRAI \"SAY HI\"",
"album": "T\u1eacP 10 - ANH TRAI \"SAY HI\"",
"duration": 258,
"cover_url": "https://lh3.googleusercontent.com/mcirwaIBLC_TC6bqKsR0YRNIgm64b8FiZNyXEX4fYjplFbLVsJBTTVh9q6xsyjeyoQC9_cAV1NG6YKA=w120-h120-l90-rj",
"id": "jMjp_GWggOA",
"url": "https://music.youtube.com/watch?v=jMjp_GWggOA"
},
{
"title": "Kh\u00f4ng Th\u1ec3 Say",
"artist": "HIEUTHUHAI",
"album": "\ud83d\udcbf Vietnam \ud83c\udfa7 TOP 50 \ud83d\udcbf Music Chart \ud83c\udfa7 Full SPOTIFY Video Playlist \ud83d\udcbf Updated Weekly \ud83c\udfa7",
"duration": 261,
"cover_url": "https://i.ytimg.com/vi/i0nd3NPJ4MI/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3kpxPJNBIFUrjPyNXj9KSzyZhPacA",
"id": "i0nd3NPJ4MI",
"url": "https://music.youtube.com/watch?v=i0nd3NPJ4MI"
},
{
"title": "Ch\u1ecbu c\u00e1ch m\u00ecnh n\u00f3i thua",
"artist": "COOLKID , RHYDER, BAN",
"album": "\ud83d\udcbf Vietnam \ud83c\udfa7 TOP 50 \ud83d\udcbf Music Chart \ud83c\udfa7 Full SPOTIFY Video Playlist \ud83d\udcbf Updated Weekly \ud83c\udfa7",
"duration": 193,
"cover_url": "https://i.ytimg.com/vi/dm5-tn1Rug0/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3nFnPXT7foRtqF89FTZzWrkrdGqoA",
"id": "dm5-tn1Rug0",
"url": "https://music.youtube.com/watch?v=dm5-tn1Rug0"
},
{
"title": "id thang m\u00e1y (feat. 267)",
"artist": "W/n",
"album": "\ud83d\udcbf Vietnam \ud83c\udfa7 TOP 50 \ud83d\udcbf Music Chart \ud83c\udfa7 Full SPOTIFY Video Playlist \ud83d\udcbf Updated Weekly \ud83c\udfa7",
"duration": 132,
"cover_url": "https://i.ytimg.com/vi/qDE-veU-roI/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3mLGvve83scPNVS2RWf0J8FE2eQIQ",
"id": "qDE-veU-roI",
"url": "https://music.youtube.com/watch?v=qDE-veU-roI"
},
{
"title": "BADBYE",
"artist": "WEAN",
"album": "\ud83d\udcbf Vietnam \ud83c\udfa7 TOP 50 \ud83d\udcbf Music Chart \ud83c\udfa7 Full SPOTIFY Video Playlist \ud83d\udcbf Updated Weekly \ud83c\udfa7",
"duration": 213,
"cover_url": "https://i.ytimg.com/vi/yhWCh5IVE04/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3m7JjyuA67IUsHF0vJMVhZ6ctvNgw",
"id": "yhWCh5IVE04",
"url": "https://music.youtube.com/watch?v=yhWCh5IVE04"
},
{
"title": "LOU HO\u00c0NG - NG\u00c0Y \u0110\u1eb8P TR\u1edcI \u0110\u1ec2 N\u00d3I CHIA TAY (Official Music Video)",
"artist": "Lou Ho\u00e0ng",
"album": "\ud83d\udcbf Vietnam \ud83c\udfa7 TOP 50 \ud83d\udcbf Music Chart \ud83c\udfa7 Full SPOTIFY Video Playlist \ud83d\udcbf Updated Weekly \ud83c\udfa7",
"duration": 231,
"cover_url": "https://i.ytimg.com/vi/0xAW6MAT_Wo/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3nPE9mrfq0gILpgA16nZDyF_f41lQ",
"id": "0xAW6MAT_Wo",
"url": "https://music.youtube.com/watch?v=0xAW6MAT_Wo"
},
{
"title": "Nh\u1eafn nh\u1ee7 | Ronboogz (Lyrics video)",
"artist": "Ronboogz",
"album": "\ud83d\udcbf Vietnam \ud83c\udfa7 TOP 50 \ud83d\udcbf Music Chart \ud83c\udfa7 Full SPOTIFY Video Playlist \ud83d\udcbf Updated Weekly \ud83c\udfa7",
"duration": 242,
"cover_url": "https://i.ytimg.com/vi/vfKiaXKO44M/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3n381KndVeEVyU0CF_eZqSMS-QR6g",
"id": "vfKiaXKO44M",
"url": "https://music.youtube.com/watch?v=vfKiaXKO44M"
}, },
{ {
"title": "id 072019", "title": "id 072019",
"artist": "W/n", "artist": "W/n",
"album": "Best Vietnamese Songs 2026 - Popular Vietnamese Songs 2026 Playlist (Top Vietnamese Music 2026-2027)", "album": "\ud83d\udcbf Vietnam \ud83c\udfa7 TOP 50 \ud83d\udcbf Music Chart \ud83c\udfa7 Full SPOTIFY Video Playlist \ud83d\udcbf Updated Weekly \ud83c\udfa7",
"duration": 303, "duration": 303,
"cover_url": "https://i.ytimg.com/vi/leJb3VhQCrg/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3nWBTudc9VK3UqnpCgc_j8QYH3ugg", "cover_url": "https://i.ytimg.com/vi/leJb3VhQCrg/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3nWBTudc9VK3UqnpCgc_j8QYH3ugg",
"id": "leJb3VhQCrg", "id": "leJb3VhQCrg",
"url": "https://music.youtube.com/watch?v=leJb3VhQCrg" "url": "https://music.youtube.com/watch?v=leJb3VhQCrg"
}, },
{ {
"title": "Gi\u1edd Th\u00ec", "title": "Chuy\u1ec7n \u0110\u00f4i Ta - Emcee L (Da LAB) ft Mu\u1ed9ii (Official MV)",
"artist": "buitruonglinh",
"album": "Best Vietnamese Songs 2026 - Popular Vietnamese Songs 2026 Playlist (Top Vietnamese Music 2026-2027)",
"duration": 238,
"cover_url": "https://i.ytimg.com/vi/69ZDBWoj5YM/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3krSRZmxC0XjjdQN0wwPoZbJ-unGQ",
"id": "69ZDBWoj5YM",
"url": "https://music.youtube.com/watch?v=69ZDBWoj5YM"
},
{
"title": "ERIK - 'D\u00f9 cho t\u1eadn th\u1ebf (v\u1eabn y\u00eau em)' | Official MV | Valentine 2025",
"artist": "ERIK",
"album": "Best Vietnamese Songs 2026 - Popular Vietnamese Songs 2026 Playlist (Top Vietnamese Music 2026-2027)",
"duration": 301,
"cover_url": "https://i.ytimg.com/vi/js6JBdLzNn4/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3nh_ITbZeDs1TJfrWuPEim8MKpj9g",
"id": "js6JBdLzNn4",
"url": "https://music.youtube.com/watch?v=js6JBdLzNn4"
},
{
"title": "Ng\u00e0y Mai Ng\u01b0\u1eddi Ta L\u1ea5y Ch\u1ed3ng",
"artist": "Th\u00e0nh \u0110\u1ea1t",
"album": "Best Vietnamese Songs 2026 - Popular Vietnamese Songs 2026 Playlist (Top Vietnamese Music 2026-2027)",
"duration": 421,
"cover_url": "https://i.ytimg.com/vi/WL11bwvAYWI/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3l10haMFB1HcY7p5muA1kJL5tz1cA",
"id": "WL11bwvAYWI",
"url": "https://music.youtube.com/watch?v=WL11bwvAYWI"
},
{
"title": "B\u1ea7u Tr\u1eddi M\u1edbi (feat. Minh Toc)",
"artist": "Da LAB", "artist": "Da LAB",
"album": "Best Vietnamese Songs 2026 - Popular Vietnamese Songs 2026 Playlist (Top Vietnamese Music 2026-2027)", "album": "\ud83d\udcbf Vietnam \ud83c\udfa7 TOP 50 \ud83d\udcbf Music Chart \ud83c\udfa7 Full SPOTIFY Video Playlist \ud83d\udcbf Updated Weekly \ud83c\udfa7",
"duration": 291, "duration": 226,
"cover_url": "https://i.ytimg.com/vi/Z1D26z9l8y8/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3k34PODWHnu_p49YHu35__8V-4avw", "cover_url": "https://i.ytimg.com/vi/6eONmnFB9sw/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3mVwjdT_-mZ2QAlwE7xqAKAQAVAlA",
"id": "Z1D26z9l8y8", "id": "6eONmnFB9sw",
"url": "https://music.youtube.com/watch?v=Z1D26z9l8y8" "url": "https://music.youtube.com/watch?v=6eONmnFB9sw"
}, },
{ {
"title": "C\u00e1nh Hoa H\u00e9o T\u00e0n (DJ Trang Moon Remix)", "title": "MI\u00caN MAN",
"artist": "ACV, Mochiii", "artist": "Minh Huy",
"album": "Best Vietnamese Songs 2026 - Popular Vietnamese Songs 2026 Playlist (Top Vietnamese Music 2026-2027)", "album": "\ud83d\udcbf Vietnam \ud83c\udfa7 TOP 50 \ud83d\udcbf Music Chart \ud83c\udfa7 Full SPOTIFY Video Playlist \ud83d\udcbf Updated Weekly \ud83c\udfa7",
"duration": 265, "duration": 205,
"cover_url": "https://i.ytimg.com/vi/YizrmzMvr7Q/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3luIG3PhCNjJlZjuCRBwAKKrMPt9Q", "cover_url": "https://i.ytimg.com/vi/7uX_f8YzEiI/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3mNH-OfD4NzEJA1co0Lc1fikXUKpw",
"id": "YizrmzMvr7Q", "id": "7uX_f8YzEiI",
"url": "https://music.youtube.com/watch?v=YizrmzMvr7Q" "url": "https://music.youtube.com/watch?v=7uX_f8YzEiI"
}, },
{ {
"title": "SOOBIN - gi\u00e1 nh\u01b0 | 'B\u1eacT N\u00d3 L\u00caN' Album (Music Video)", "title": "CH\u00daNG TA C\u1ee6A T\u01af\u01a0NG LAI",
"artist": "SOOBIN", "artist": "S\u01a1n T\u00f9ng M-TP",
"album": "Best Vietnamese Songs 2026 - Popular Vietnamese Songs 2026 Playlist (Top Vietnamese Music 2026-2027)", "album": "\ud83d\udcbf Vietnam \ud83c\udfa7 TOP 50 \ud83d\udcbf Music Chart \ud83c\udfa7 Full SPOTIFY Video Playlist \ud83d\udcbf Updated Weekly \ud83c\udfa7",
"duration": 310,
"cover_url": "https://i.ytimg.com/vi/SeWt7IpZ0CA/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3lYIMR-uDbHo5-B3GO0z5XPqKIcaQ",
"id": "SeWt7IpZ0CA",
"url": "https://music.youtube.com/watch?v=SeWt7IpZ0CA"
},
{
"title": "Vuon Hoa Con Ca",
"artist": "Olew",
"album": "Best Vietnamese Songs 2026 - Popular Vietnamese Songs 2026 Playlist (Top Vietnamese Music 2026-2027)",
"duration": 234,
"cover_url": "https://i.ytimg.com/vi/BFflHDlTeHw/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3nA_WhH_ZnanoXMGeQ-4d4hYSUbUg",
"id": "BFflHDlTeHw",
"url": "https://music.youtube.com/watch?v=BFflHDlTeHw"
},
{
"title": "G\u1eb7p L\u1ea1i N\u0103m Ta 60",
"artist": "Orange",
"album": "Best Vietnamese Songs 2026 - Popular Vietnamese Songs 2026 Playlist (Top Vietnamese Music 2026-2027)",
"duration": 337,
"cover_url": "https://i.ytimg.com/vi/ZXNrz72k1ew/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3kqqJWC4ogBjc4u12JzCHk2YMBKWA",
"id": "ZXNrz72k1ew",
"url": "https://music.youtube.com/watch?v=ZXNrz72k1ew"
},
{
"title": "You're The Problem",
"artist": "Dax",
"album": "Best Vietnamese Songs 2026 - Popular Vietnamese Songs 2026 Playlist (Top Vietnamese Music 2026-2027)",
"duration": 230,
"cover_url": "https://i.ytimg.com/vi/-kA2ReyByZU/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3kg2w-R3-05ocVT0g03RyIQJ41F4Q",
"id": "-kA2ReyByZU",
"url": "https://music.youtube.com/watch?v=-kA2ReyByZU"
},
{
"title": "SOOBIN - Dancing In The Dark | 'B\u1eacT N\u00d3 L\u00caN' Album",
"artist": "SOOBIN",
"album": "Best Vietnamese Songs 2026 - Popular Vietnamese Songs 2026 Playlist (Top Vietnamese Music 2026-2027)",
"duration": 279,
"cover_url": "https://i.ytimg.com/vi/OZmK0YuSmXU/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3mBF8aJUqrrJIQduCkE_BAGkeucDA",
"id": "OZmK0YuSmXU",
"url": "https://music.youtube.com/watch?v=OZmK0YuSmXU"
},
{
"title": "Lao T\u00e2m Kh\u1ed5 T\u1ee9",
"artist": "Thanh H\u01b0ng",
"album": "Best Vietnamese Songs 2026 - Popular Vietnamese Songs 2026 Playlist (Top Vietnamese Music 2026-2027)",
"duration": 348,
"cover_url": "https://i.ytimg.com/vi/TfKOFRpqSME/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3n85vMTLZIA2MAj83vqnYk4pomt0Q",
"id": "TfKOFRpqSME",
"url": "https://music.youtube.com/watch?v=TfKOFRpqSME"
},
{
"title": "N\u1ea5u \u0102n Cho Em (feat. PIALINH)",
"artist": "\u0110en",
"album": "Best Vietnamese Songs 2026 - Popular Vietnamese Songs 2026 Playlist (Top Vietnamese Music 2026-2027)",
"duration": 257,
"cover_url": "https://i.ytimg.com/vi/ukHK1GVyr0I/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3nD2JOikDSq_cCeBaG-VH6LBYriJg",
"id": "ukHK1GVyr0I",
"url": "https://music.youtube.com/watch?v=ukHK1GVyr0I"
},
{
"title": "T\u1ebft B\u00ecnh An Remix, Hana C\u1ea9m Ti\u00ean, \u0110\u1ea1i M\u00e8o | M\u1ed9t N\u0103m C\u0169 \u0110\u00e3 Qua C\u00f9ng Nhau \u0110\u00f3n N\u0103m M\u1edbi B\u00ecnh An Mu\u00f4n Nh\u00e0",
"artist": "BD Media Music",
"album": "Best Vietnamese Songs 2026 - Popular Vietnamese Songs 2026 Playlist (Top Vietnamese Music 2026-2027)",
"duration": 227,
"cover_url": "https://i.ytimg.com/vi/fMskPmI4tp0/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3m8nsW6nX2B8BJf4gsU36uDsmDCgw",
"id": "fMskPmI4tp0",
"url": "https://music.youtube.com/watch?v=fMskPmI4tp0"
},
{
"title": "T\u1eebng L\u00e0",
"artist": "V\u0169 C\u00e1t T\u01b0\u1eddng",
"album": "Best Vietnamese Songs 2026 - Popular Vietnamese Songs 2026 Playlist (Top Vietnamese Music 2026-2027)",
"duration": 277, "duration": 277,
"cover_url": "https://i.ytimg.com/vi/i4qZmKSFYvI/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3kLaE-0VAlEfGQRlKBACGiK0w0WDw", "cover_url": "https://i.ytimg.com/vi/zoEtcR5EW08/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3lv4_jfNfESnK0mh8F5gKgJ7h1vUw",
"id": "i4qZmKSFYvI", "id": "zoEtcR5EW08",
"url": "https://music.youtube.com/watch?v=i4qZmKSFYvI" "url": "https://music.youtube.com/watch?v=zoEtcR5EW08"
}, },
{ {
"title": "N\u01a1i Ph\u00e1o Hoa R\u1ef1c R\u1ee1 (feat. C\u1ea9m V\u00e2n)", "title": "NOLOVENOLIFE",
"artist": "Hua Kim Tuyen, Orange, Ho\u00e0ng D\u0169ng", "artist": "HIEUTHUHAI",
"album": "Best Vietnamese Songs 2026 - Popular Vietnamese Songs 2026 Playlist (Top Vietnamese Music 2026-2027)", "album": "\ud83d\udcbf Vietnam \ud83c\udfa7 TOP 50 \ud83d\udcbf Music Chart \ud83c\udfa7 Full SPOTIFY Video Playlist \ud83d\udcbf Updated Weekly \ud83c\udfa7",
"duration": 310, "duration": 172,
"cover_url": "https://i.ytimg.com/vi/BgUFNi5MvzE/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3mGOmc3dRUaQczZnhubm_nb8Gs_Uw", "cover_url": "https://i.ytimg.com/vi/F084mTHtBpI/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3mhKw7o_AGrSUJzBbFcDXst7hK2jA",
"id": "BgUFNi5MvzE", "id": "F084mTHtBpI",
"url": "https://music.youtube.com/watch?v=BgUFNi5MvzE" "url": "https://music.youtube.com/watch?v=F084mTHtBpI"
}, },
{ {
"title": "Ng\u01b0\u1eddi B\u00ecnh Th\u01b0\u1eddng", "title": "Mo",
"artist": "V\u0169 C\u00e1t T\u01b0\u1eddng", "artist": "V\u0169 C\u00e1t T\u01b0\u1eddng",
"album": "Best Vietnamese Songs 2026 - Popular Vietnamese Songs 2026 Playlist (Top Vietnamese Music 2026-2027)", "album": "\ud83d\udcbf Vietnam \ud83c\udfa7 TOP 50 \ud83d\udcbf Music Chart \ud83c\udfa7 Full SPOTIFY Video Playlist \ud83d\udcbf Updated Weekly \ud83c\udfa7",
"duration": 285, "duration": 334,
"cover_url": "https://i.ytimg.com/vi/X5KvHXWPYm4/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3lPWTFBiFDjAliZkS614MkwVcte1g", "cover_url": "https://i.ytimg.com/vi/2YM4j-oP_qQ/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3lzpPjF9OCGDVvOwuwbLtyGa7Pi4A",
"id": "X5KvHXWPYm4", "id": "2YM4j-oP_qQ",
"url": "https://music.youtube.com/watch?v=X5KvHXWPYm4" "url": "https://music.youtube.com/watch?v=2YM4j-oP_qQ"
}, },
{ {
"title": "C\u00f3 Em L\u00e0 \u0110i\u1ec1u Tuy\u1ec7t V\u1eddi Nh\u1ea5t (Th\u1ecbnh H\u00e0nh)", "title": "WEAN \u2013 shhhhhhh.. feat tlinh (Official Lyrics Video)",
"artist": "Thi\u00ean T\u00fa", "artist": "WEAN",
"album": "Best Vietnamese Songs 2026 - Popular Vietnamese Songs 2026 Playlist (Top Vietnamese Music 2026-2027)", "album": "\ud83d\udcbf Vietnam \ud83c\udfa7 TOP 50 \ud83d\udcbf Music Chart \ud83c\udfa7 Full SPOTIFY Video Playlist \ud83d\udcbf Updated Weekly \ud83c\udfa7",
"duration": 371, "duration": 238,
"cover_url": "https://i.ytimg.com/vi/IenfKDtyMI0/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3nbyyByYoJQ2qV7-2w4S6Gyofj9dQ", "cover_url": "https://i.ytimg.com/vi/Pys2iOT9rpw/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3mXx9k9v4Z61a5lDYIb6s5wJ829XQ",
"id": "IenfKDtyMI0", "id": "Pys2iOT9rpw",
"url": "https://music.youtube.com/watch?v=IenfKDtyMI0" "url": "https://music.youtube.com/watch?v=Pys2iOT9rpw"
}, },
{ {
"title": "Nh\u1eefng L\u1eddi H\u1ee9a B\u1ecf Qu\u00ean", "title": "HURRYKNG, HIEUTHUHAI, MANBO | H\u1eb9n G\u1eb7p Em D\u01b0\u1edbi \u00c1nh Tr\u0103ng | Official Video",
"artist": "V\u0169., Dear Jane", "artist": "GERDNANG",
"album": "Best Vietnamese Songs 2026 - Popular Vietnamese Songs 2026 Playlist (Top Vietnamese Music 2026-2027)", "album": "\ud83d\udcbf Vietnam \ud83c\udfa7 TOP 50 \ud83d\udcbf Music Chart \ud83c\udfa7 Full SPOTIFY Video Playlist \ud83d\udcbf Updated Weekly \ud83c\udfa7",
"duration": 259, "duration": 232,
"cover_url": "https://i.ytimg.com/vi/h6RONxjPBf4/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3nfvRCueWOo-OjD8_3sK9HSlhvoSw", "cover_url": "https://i.ytimg.com/vi/dLmczwDCEZI/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3kSMrrgkOYzN73RZZ9YG9WoUKG5xg",
"id": "h6RONxjPBf4", "id": "dLmczwDCEZI",
"url": "https://music.youtube.com/watch?v=h6RONxjPBf4" "url": "https://music.youtube.com/watch?v=dLmczwDCEZI"
}, },
{ {
"title": "m\u1ed9t b\u00e0i h\u00e1t kh\u00f4ng vui m\u1ea5y (Extended Version)", "title": "Obito - H\u00e0 N\u1ed9i ft. VSTRA",
"artist": "T.R.I, Dangrangto, DONAL", "artist": "Obito",
"album": "Best Vietnamese Songs 2026 - Popular Vietnamese Songs 2026 Playlist (Top Vietnamese Music 2026-2027)", "album": "\ud83d\udcbf Vietnam \ud83c\udfa7 TOP 50 \ud83d\udcbf Music Chart \ud83c\udfa7 Full SPOTIFY Video Playlist \ud83d\udcbf Updated Weekly \ud83c\udfa7",
"duration": 230, "duration": 166,
"cover_url": "https://i.ytimg.com/vi/EvPEeSBfB3E/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3kvhX3tBQICPMgOEn0R9uswYvdC5A", "cover_url": "https://i.ytimg.com/vi/OerAX-zKyvg/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3nO_yPfoJxp7C-FpHpc8p9sN47q1A",
"id": "EvPEeSBfB3E", "id": "OerAX-zKyvg",
"url": "https://music.youtube.com/watch?v=EvPEeSBfB3E" "url": "https://music.youtube.com/watch?v=OerAX-zKyvg"
}, },
{ {
"title": "One Time", "title": "LOVE SAND (feat. HIEUTHUHAI, JSOL, ALI HO\u00c0NG D\u01af\u01a0NG & V\u0168 TH\u1ecaNH)",
"artist": "Raw Dawg", "artist": "ANH TRAI \"SAY HI\"",
"album": "Best Vietnamese Songs 2026 - Popular Vietnamese Songs 2026 Playlist (Top Vietnamese Music 2026-2027)", "album": "T\u1eacP 4 - ANH TRAI \"SAY HI\"",
"duration": 119, "duration": 236,
"cover_url": "https://i.ytimg.com/vi/ylh1oDhP2AE/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3lRAtyI5EucwyXxXGb9YLfFY2W6aQ", "cover_url": "https://lh3.googleusercontent.com/OWVfxVgRseYQVQcPzWcQ1bHhiYSfCxxiMqK5HDH7JXFdbRoo9RNr2-YbjdSwBGjk3Cz5l9DAetYOprVG=w120-h120-l90-rj",
"id": "ylh1oDhP2AE", "id": "cSjF9UkTWqg",
"url": "https://music.youtube.com/watch?v=ylh1oDhP2AE" "url": "https://music.youtube.com/watch?v=cSjF9UkTWqg"
}, },
{ {
"title": "MIN - ch\u1eb3ng ph\u1ea3i t\u00ecnh \u0111\u1ea7u sao \u0111au \u0111\u1ebfn th\u1ebf feat. Dangrangto, Antransax (Official Audio)", "title": "An Th\u1ea7n (ft. Th\u1eafng) | Low G | Rap Nh\u00e0 L\u00e0m",
"artist": "MIN", "artist": "Rap Nh\u00e0 L\u00e0m",
"album": "Best Vietnamese Songs 2026 - Popular Vietnamese Songs 2026 Playlist (Top Vietnamese Music 2026-2027)", "album": "\ud83d\udcbf Vietnam \ud83c\udfa7 TOP 50 \ud83d\udcbf Music Chart \ud83c\udfa7 Full SPOTIFY Video Playlist \ud83d\udcbf Updated Weekly \ud83c\udfa7",
"duration": 284, "duration": 216,
"cover_url": "https://i.ytimg.com/vi/rLNvDu59ffI/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3nfhSKyeyGqrokp13H6G7C1rNvKLg", "cover_url": "https://i.ytimg.com/vi/J7eYhM6wXPo/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3kPN37KoE_QGfGty9ZPpJj2tYxa0A",
"id": "rLNvDu59ffI", "id": "J7eYhM6wXPo",
"url": "https://music.youtube.com/watch?v=rLNvDu59ffI" "url": "https://music.youtube.com/watch?v=J7eYhM6wXPo"
},
{
"title": "Ng\u01b0\u1eddi \u0110\u1ea7u Ti\u00ean",
"artist": "Juky San, buitruonglinh",
"album": "Best Vietnamese Songs 2026 - Popular Vietnamese Songs 2026 Playlist (Top Vietnamese Music 2026-2027)",
"duration": 220,
"cover_url": "https://i.ytimg.com/vi/i54avTdUqwU/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3klVzmY8CCpa5CLEP3BIema5Lhgyw",
"id": "i54avTdUqwU",
"url": "https://music.youtube.com/watch?v=i54avTdUqwU"
},
{
"title": "MIN - ch\u1eb3ng ph\u1ea3i t\u00ecnh \u0111\u1ea7u sao \u0111au \u0111\u1ebfn th\u1ebf feat. Dangrangto, antransax (Official Visual Stage)",
"artist": "MIN OFFICIAL",
"album": "Best Vietnamese Songs 2026 - Popular Vietnamese Songs 2026 Playlist (Top Vietnamese Music 2026-2027)",
"duration": 288,
"cover_url": "https://i.ytimg.com/vi/s0OMNH-N5D8/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3k_uFbHN8ud3zNWnb5hdzcYLhUgWA",
"id": "s0OMNH-N5D8",
"url": "https://music.youtube.com/watch?v=s0OMNH-N5D8"
},
{
"title": "Em",
"artist": "Binz",
"album": "Best Vietnamese Songs 2026 - Popular Vietnamese Songs 2026 Playlist (Top Vietnamese Music 2026-2027)",
"duration": 208,
"cover_url": "https://i.ytimg.com/vi/CU2PtRKBkuw/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3mnGQ84aQvDmEMNCd5m6b-_pyKbUg",
"id": "CU2PtRKBkuw",
"url": "https://music.youtube.com/watch?v=CU2PtRKBkuw"
},
{
"title": "HO\u1ea0 S\u0128 T\u1ed2I - TH\u00c1I H\u1eccC x \u0110\u1ea0T MAX | Official MV | Anh v\u1ebd c\u1ea7u v\u1ed3ng th\u00ec l\u1ea1i thi\u1ebfu n\u1eafng",
"artist": "Th\u00e1i H\u1ecdc",
"album": "Best Vietnamese Songs 2026 - Popular Vietnamese Songs 2026 Playlist (Top Vietnamese Music 2026-2027)",
"duration": 285,
"cover_url": "https://i.ytimg.com/vi/RF0jYdTXQK4/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3nAfOOBrWfNICLXduP5GPktAPARCg",
"id": "RF0jYdTXQK4",
"url": "https://music.youtube.com/watch?v=RF0jYdTXQK4"
},
{
"title": "T\u00ecnh Nh\u01b0 L\u00e1 Bay Xa (Live)",
"artist": "Jimmy Nguyen, M\u1ef8 T\u00c2M",
"album": "Best Vietnamese Songs 2026 - Popular Vietnamese Songs 2026 Playlist (Top Vietnamese Music 2026-2027)",
"duration": 273,
"cover_url": "https://i.ytimg.com/vi/gxPoI_tldfQ/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3nQp0dsN1t1shvvjBq0A2m-EyfvBg",
"id": "gxPoI_tldfQ",
"url": "https://music.youtube.com/watch?v=gxPoI_tldfQ"
},
{
"title": "Kh\u1ed5ng Minh x Ch\u00e2u Nhu\u1eadn Ph\u00e1t - ''E L\u00c0 \u0110\u00d4N CH\u1ec0'' Prod.@tiengaz",
"artist": "Dagoats House",
"album": "Best Vietnamese Songs 2026 - Popular Vietnamese Songs 2026 Playlist (Top Vietnamese Music 2026-2027)",
"duration": 191,
"cover_url": "https://i.ytimg.com/vi/K01LvulhFRg/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3n8fN2PiGDRGtGv0VZMp_OOW2kBoQ",
"id": "K01LvulhFRg",
"url": "https://music.youtube.com/watch?v=K01LvulhFRg"
},
{
"title": "M\u1ee5c H\u1ea1 V\u00f4 Nh\u00e2n (feat. Binz)",
"artist": "SOOBIN, Hu\u1ef3nh T\u00fa",
"album": "Best Vietnamese Songs 2026 - Popular Vietnamese Songs 2026 Playlist (Top Vietnamese Music 2026-2027)",
"duration": 355,
"cover_url": "https://i.ytimg.com/vi/FikdKWos-NQ/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3lgfIwIcM4zdZGPxZN-kcs96iJyGQ",
"id": "FikdKWos-NQ",
"url": "https://music.youtube.com/watch?v=FikdKWos-NQ"
},
{
"title": "10 M\u1ea4T 1 C\u00d2N KH\u00d4NG - TH\u00c1I H\u1eccC x L\u00ca GIA B\u1ea2O (New Version) | St: Long H\u1ecd Hu\u1ef3nh",
"artist": "Th\u00e1i H\u1ecdc",
"album": "Best Vietnamese Songs 2026 - Popular Vietnamese Songs 2026 Playlist (Top Vietnamese Music 2026-2027)",
"duration": 285,
"cover_url": "https://i.ytimg.com/vi/9HnyyKg0M-Y/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3lyCTROUhGaahuDenut3oMfnOesDQ",
"id": "9HnyyKg0M-Y",
"url": "https://music.youtube.com/watch?v=9HnyyKg0M-Y"
},
{
"title": "Mr Siro | Day D\u1ee9t N\u1ed7i \u0110au",
"artist": "Mr. Siro",
"album": "Best Vietnamese Songs 2026 - Popular Vietnamese Songs 2026 Playlist (Top Vietnamese Music 2026-2027)",
"duration": 368,
"cover_url": "https://i.ytimg.com/vi/N4Xak1n497M/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3nZ6HzRlVHFya6aliEsGSZuGB_QxA",
"id": "N4Xak1n497M",
"url": "https://music.youtube.com/watch?v=N4Xak1n497M"
},
{
"title": "Diary Of A Trying Man",
"artist": "Dax",
"album": "Best Vietnamese Songs 2026 - Popular Vietnamese Songs 2026 Playlist (Top Vietnamese Music 2026-2027)",
"duration": 234,
"cover_url": "https://i.ytimg.com/vi/WulTil-Wwoo/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3lC8LD69LfTh3hrClQoWJGA3pCjCw",
"id": "WulTil-Wwoo",
"url": "https://music.youtube.com/watch?v=WulTil-Wwoo"
},
{
"title": "Feel At Home",
"artist": "B Ray",
"album": "Best Vietnamese Songs 2026 - Popular Vietnamese Songs 2026 Playlist (Top Vietnamese Music 2026-2027)",
"duration": 164,
"cover_url": "https://i.ytimg.com/vi/6x1yluqMuc4/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3nfaiwiYPKbD_v3rvKR1QC1Sw9Znw",
"id": "6x1yluqMuc4",
"url": "https://music.youtube.com/watch?v=6x1yluqMuc4"
},
{
"title": "L\u00e1 Th\u01b0 \u0110\u00f4 Th\u1ecb",
"artist": "Th\u00fay H\u00e0",
"album": "Best Vietnamese Songs 2026 - Popular Vietnamese Songs 2026 Playlist (Top Vietnamese Music 2026-2027)",
"duration": 355,
"cover_url": "https://i.ytimg.com/vi/42m7T272u8I/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3moQljTHbKdPZ3c48rcbJiq4KILjQ",
"id": "42m7T272u8I",
"url": "https://music.youtube.com/watch?v=42m7T272u8I"
},
{
"title": "R\u1eddi B\u1ecf N\u01a1i Anh Bi\u1ebft Em C\u00f3 Vui B\u00ean Ng\u01b0\u1eddi Remix | TH\u01af\u01a0NG TH\u00cc TH\u00d4I REMIX B\u1ea3n Si\u00eau Th\u1ea5m BeBe...",
"artist": "ACV",
"album": "Best Vietnamese Songs 2026 - Popular Vietnamese Songs 2026 Playlist (Top Vietnamese Music 2026-2027)",
"duration": 195,
"cover_url": "https://i.ytimg.com/vi/Hq_Q9vSIg2M/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3n3JlqdmwyqK_me1eqnMQVrNeL6ZA",
"id": "Hq_Q9vSIg2M",
"url": "https://music.youtube.com/watch?v=Hq_Q9vSIg2M"
},
{
"title": "Gi\u1eef Anh Cho Ng\u00e0y H\u00f4m Qua",
"artist": "Ho\u00e0ng D\u0169ng, Rhymastic, Lelarec",
"album": "Best Vietnamese Songs 2026 - Popular Vietnamese Songs 2026 Playlist (Top Vietnamese Music 2026-2027)",
"duration": 345,
"cover_url": "https://i.ytimg.com/vi/IADhKnmQMtk/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3nzDVE9hb0vpWAYZ39Ghi-6BrG-9g",
"id": "IADhKnmQMtk",
"url": "https://music.youtube.com/watch?v=IADhKnmQMtk"
},
{
"title": "Mr Siro | T\u1ef1 Lau N\u01b0\u1edbc M\u1eaft",
"artist": "Mr. Siro",
"album": "Best Vietnamese Songs 2026 - Popular Vietnamese Songs 2026 Playlist (Top Vietnamese Music 2026-2027)",
"duration": 330,
"cover_url": "https://i.ytimg.com/vi/tgvXGxCrBmE/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3mNQ5AIuHnGA4XZQwvFh_WRi1DmAg",
"id": "tgvXGxCrBmE",
"url": "https://music.youtube.com/watch?v=tgvXGxCrBmE"
},
{
"title": "She Never Cries (feat. S\u01a0N.K)",
"artist": "Ho\u00e0ng Duy\u00ean",
"album": "Best Vietnamese Songs 2026 - Popular Vietnamese Songs 2026 Playlist (Top Vietnamese Music 2026-2027)",
"duration": 264,
"cover_url": "https://i.ytimg.com/vi/zuBjkHOFVJs/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3kdUzhae-xLYnaf7b45tXbDDxr71A",
"id": "zuBjkHOFVJs",
"url": "https://music.youtube.com/watch?v=zuBjkHOFVJs"
},
{
"title": "Ch\u1edd Bao L\u00e2u (feat. H\u00e0o JK)",
"artist": "\u00dat Nh\u1ecb Mino",
"album": "Best Vietnamese Songs 2026 - Popular Vietnamese Songs 2026 Playlist (Top Vietnamese Music 2026-2027)",
"duration": 193,
"cover_url": "https://i.ytimg.com/vi/KO0CbNNvd14/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3mL5syc6JwJoWuHasdnfhrSAFITHA",
"id": "KO0CbNNvd14",
"url": "https://music.youtube.com/watch?v=KO0CbNNvd14"
},
{
"title": "C\u00d4 G\u00c1I \u00c0 \u0110\u1eeaNG KH\u00d3C | \u00daT NH\u1eca MINO FT NVC MUSIC",
"artist": "\u00dat Nh\u1ecb Mino",
"album": "Best Vietnamese Songs 2026 - Popular Vietnamese Songs 2026 Playlist (Top Vietnamese Music 2026-2027)",
"duration": 266,
"cover_url": "https://i.ytimg.com/vi/oH9_c7Y5zMQ/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3kBh2R8cqyDQN98Jd9CIO1RZBbVNQ",
"id": "oH9_c7Y5zMQ",
"url": "https://music.youtube.com/watch?v=oH9_c7Y5zMQ"
},
{
"title": "R\u1ea5t L\u00e2u R\u1ed3i M\u1edbi Kh\u00f3c (Solo Version)",
"artist": "",
"album": "Best Vietnamese Songs 2026 - Popular Vietnamese Songs 2026 Playlist (Top Vietnamese Music 2026-2027)",
"duration": 311,
"cover_url": "https://i.ytimg.com/vi/MWowv3A-fQc/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3kWiKMUSyg-xYgdrzO26ICDnO6Gpg",
"id": "MWowv3A-fQc",
"url": "https://music.youtube.com/watch?v=MWowv3A-fQc"
},
{
"title": "Ring Ring",
"artist": "MIRA",
"album": "Best Vietnamese Songs 2026 - Popular Vietnamese Songs 2026 Playlist (Top Vietnamese Music 2026-2027)",
"duration": 147,
"cover_url": "https://i.ytimg.com/vi/mkCaf6tuhGM/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3lyjmmeuudBzy9Yu64rGLaWENa4tg",
"id": "mkCaf6tuhGM",
"url": "https://music.youtube.com/watch?v=mkCaf6tuhGM"
},
{
"title": "B\u1ea3o Anh | C\u00f4 \u1ea4y C\u1ee7a Anh \u1ea4y",
"artist": "B\u1ea3o Anh ",
"album": "Best Vietnamese Songs 2026 - Popular Vietnamese Songs 2026 Playlist (Top Vietnamese Music 2026-2027)",
"duration": 324,
"cover_url": "https://i.ytimg.com/vi/RlTDbIutJsU/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3kTrRLvQFATZub9py_upYtU7SUaJQ",
"id": "RlTDbIutJsU",
"url": "https://music.youtube.com/watch?v=RlTDbIutJsU"
},
{
"title": "\u0110\u1ecaA \u0110\u00c0NG REMIX - HO\u00c0NG OANH x ACV | N\u00f3i Anh Nghe \u0110\u1ecba \u0110\u00e0ng M\u1edf C\u1eeda L\u00e0 \u0110\u1ec3 Ch\u1edd B\u01b0\u1edbc Ch\u00e2n Em Ph\u1ea3i Kh\u00f4ng ?",
"artist": "ACV",
"album": "Best Vietnamese Songs 2026 - Popular Vietnamese Songs 2026 Playlist (Top Vietnamese Music 2026-2027)",
"duration": 311,
"cover_url": "https://i.ytimg.com/vi/vZzzcflS2HM/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3lMoxUQD5_wrptAPCqUYBxD0MKndw",
"id": "vZzzcflS2HM",
"url": "https://music.youtube.com/watch?v=vZzzcflS2HM"
}, },
{ {
"title": "T\u1eebng quen", "title": "T\u1eebng quen",
"artist": "itsnk, Wren Evans", "artist": "itsnk, Wren Evans",
"album": "Best Vietnamese Songs 2026 - Popular Vietnamese Songs 2026 Playlist (Top Vietnamese Music 2026-2027)", "album": "\ud83d\udcbf Vietnam \ud83c\udfa7 TOP 50 \ud83d\udcbf Music Chart \ud83c\udfa7 Full SPOTIFY Video Playlist \ud83d\udcbf Updated Weekly \ud83c\udfa7",
"duration": 175, "duration": 175,
"cover_url": "https://i.ytimg.com/vi/zepHPnUDROE/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3kQphjp4tiW4vFcaXJBk1wMtsk9Kg", "cover_url": "https://i.ytimg.com/vi/zepHPnUDROE/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3kQphjp4tiW4vFcaXJBk1wMtsk9Kg",
"id": "zepHPnUDROE", "id": "zepHPnUDROE",
"url": "https://music.youtube.com/watch?v=zepHPnUDROE" "url": "https://music.youtube.com/watch?v=zepHPnUDROE"
}, },
{ {
"title": "HOA B\u1ea4T T\u1eec", "title": "T\u1eebng L\u00e0",
"artist": "Th\u00e0nh \u0110\u1ea1t", "artist": "V\u0169 C\u00e1t T\u01b0\u1eddng",
"album": "Best Vietnamese Songs 2026 - Popular Vietnamese Songs 2026 Playlist (Top Vietnamese Music 2026-2027)", "album": "\ud83d\udcbf Vietnam \ud83c\udfa7 TOP 50 \ud83d\udcbf Music Chart \ud83c\udfa7 Full SPOTIFY Video Playlist \ud83d\udcbf Updated Weekly \ud83c\udfa7",
"duration": 317, "duration": 277,
"cover_url": "https://i.ytimg.com/vi/n-k_aUsOaaQ/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3lfd3LBuB7aTSG880J0HqdjEqNQww", "cover_url": "https://i.ytimg.com/vi/i4qZmKSFYvI/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3kLaE-0VAlEfGQRlKBACGiK0w0WDw",
"id": "n-k_aUsOaaQ", "id": "i4qZmKSFYvI",
"url": "https://music.youtube.com/watch?v=n-k_aUsOaaQ" "url": "https://music.youtube.com/watch?v=i4qZmKSFYvI"
}, },
{ {
"title": "N\u00f3i D\u1ed1i | Ronboogz (Lyrics Video)", "title": "CATCH ME IF YOU CAN - NEGAV x Quang H\u00f9ng MasterD x Nicky x C\u00f4ng D\u01b0\u01a1ng | ANH TRAI SAY HI",
"artist": "Ronboogz", "artist": "Negav",
"album": "Best Vietnamese Songs 2026 - Popular Vietnamese Songs 2026 Playlist (Top Vietnamese Music 2026-2027)", "album": "\ud83d\udcbf Vietnam \ud83c\udfa7 TOP 50 \ud83d\udcbf Music Chart \ud83c\udfa7 Full SPOTIFY Video Playlist \ud83d\udcbf Updated Weekly \ud83c\udfa7",
"duration": 207, "duration": 282,
"cover_url": "https://i.ytimg.com/vi/ri-TFS97Hbw/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3lgdDfcXHekuevzN7qPIZR7RryanQ", "cover_url": "https://i.ytimg.com/vi/WUbTGHzxeDI/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3njQTKSHjLv_2NRhqxzSlOiwryGAA",
"id": "ri-TFS97Hbw", "id": "WUbTGHzxeDI",
"url": "https://music.youtube.com/watch?v=ri-TFS97Hbw" "url": "https://music.youtube.com/watch?v=WUbTGHzxeDI"
}, },
{ {
"title": "MONO - \u2018Em Xinh\u2019 (Official Music Video)", "title": "Nh\u1eefng L\u1eddi H\u1ee9a B\u1ecf Qu\u00ean",
"artist": "MONO", "artist": "V\u0169., Dear Jane",
"album": "Best Vietnamese Songs 2026 - Popular Vietnamese Songs 2026 Playlist (Top Vietnamese Music 2026-2027)", "album": "\ud83d\udcbf Vietnam \ud83c\udfa7 TOP 50 \ud83d\udcbf Music Chart \ud83c\udfa7 Full SPOTIFY Video Playlist \ud83d\udcbf Updated Weekly \ud83c\udfa7",
"duration": 197, "duration": 259,
"cover_url": "https://i.ytimg.com/vi/rYc1UbgbMIY/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3mkHo5h-7KAI9SGhk2jG6m6cHospQ", "cover_url": "https://i.ytimg.com/vi/h6RONxjPBf4/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3nfvRCueWOo-OjD8_3sK9HSlhvoSw",
"id": "rYc1UbgbMIY", "id": "h6RONxjPBf4",
"url": "https://music.youtube.com/watch?v=rYc1UbgbMIY" "url": "https://music.youtube.com/watch?v=h6RONxjPBf4"
},
{
"title": "m\u1ed9t \u0111\u1eddi (feat. buitruonglinh)",
"artist": "Bon Nghi\u00eam, 14 Casper",
"album": "\ud83d\udcbf Vietnam \ud83c\udfa7 TOP 50 \ud83d\udcbf Music Chart \ud83c\udfa7 Full SPOTIFY Video Playlist \ud83d\udcbf Updated Weekly \ud83c\udfa7",
"duration": 329,
"cover_url": "https://i.ytimg.com/vi/JgTZvDbaTtg/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3lEKS8TNud8_GWknaWc0IQEQWBTgw",
"id": "JgTZvDbaTtg",
"url": "https://music.youtube.com/watch?v=JgTZvDbaTtg"
},
{
"title": "puppy & @Dangrangto - Wrong Times ( ft. FOWLEX Snowz ) [OFFICIAL LYRICS VIDEO]",
"artist": "Ocean Waves",
"album": "\ud83d\udcbf Vietnam \ud83c\udfa7 TOP 50 \ud83d\udcbf Music Chart \ud83c\udfa7 Full SPOTIFY Video Playlist \ud83d\udcbf Updated Weekly \ud83c\udfa7",
"duration": 214,
"cover_url": "https://i.ytimg.com/vi/O3pj32O5WN4/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3kIT8pcPmY-AZV1W4Znp0b8wlgdoQ",
"id": "O3pj32O5WN4",
"url": "https://music.youtube.com/watch?v=O3pj32O5WN4"
},
{
"title": "Sinh ra \u0111\u00e3 l\u00e0 th\u1ee9 \u0111\u1ed1i l\u1eadp nhau (feat. Badbies)",
"artist": "Emcee L",
"album": "\ud83d\udcbf Vietnam \ud83c\udfa7 TOP 50 \ud83d\udcbf Music Chart \ud83c\udfa7 Full SPOTIFY Video Playlist \ud83d\udcbf Updated Weekly \ud83c\udfa7",
"duration": 248,
"cover_url": "https://i.ytimg.com/vi/redFrGBZoJY/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3kHwvlcYNZc52dc1dMwoR7acMguXQ",
"id": "redFrGBZoJY",
"url": "https://music.youtube.com/watch?v=redFrGBZoJY"
},
{
"title": "Ch\u1ea1y Kh\u1ecfi Th\u1ebf Gi\u1edbi N\u00e0y (Instrumental)",
"artist": "Da LAB",
"album": "\ud83d\udcbf Vietnam \ud83c\udfa7 TOP 50 \ud83d\udcbf Music Chart \ud83c\udfa7 Full SPOTIFY Video Playlist \ud83d\udcbf Updated Weekly \ud83c\udfa7",
"duration": 284,
"cover_url": "https://i.ytimg.com/vi/hYYMF3VtOjE/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3naDMhRqjY3rGBYbSzMd7JJzIVYtg",
"id": "hYYMF3VtOjE",
"url": "https://music.youtube.com/watch?v=hYYMF3VtOjE"
},
{
"title": "Haegeum",
"artist": "Agust D & SUGA",
"album": "\ud83d\udcbf Vietnam \ud83c\udfa7 TOP 50 \ud83d\udcbf Music Chart \ud83c\udfa7 Full SPOTIFY Video Playlist \ud83d\udcbf Updated Weekly \ud83c\udfa7",
"duration": 275,
"cover_url": "https://i.ytimg.com/vi/iy9qZR_OGa0/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3mCUEnijJVusKHFSq4NRWJ99K7u9w",
"id": "iy9qZR_OGa0",
"url": "https://music.youtube.com/watch?v=iy9qZR_OGa0"
},
{
"title": "1000 \u00c1nh M\u1eaft",
"artist": "Shiki, Obito",
"album": "\ud83d\udcbf Vietnam \ud83c\udfa7 TOP 50 \ud83d\udcbf Music Chart \ud83c\udfa7 Full SPOTIFY Video Playlist \ud83d\udcbf Updated Weekly \ud83c\udfa7",
"duration": 152,
"cover_url": "https://i.ytimg.com/vi/AJDEu1-nSTI/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3nDeVx4U7u37ehqM43JjDTxqd_txA",
"id": "AJDEu1-nSTI",
"url": "https://music.youtube.com/watch?v=AJDEu1-nSTI"
},
{
"title": "n\u1ebfu l\u00fac \u0111\u00f3 (feat. 2pillz)",
"artist": "Tlinh",
"album": "\ud83d\udcbf Vietnam \ud83c\udfa7 TOP 50 \ud83d\udcbf Music Chart \ud83c\udfa7 Full SPOTIFY Video Playlist \ud83d\udcbf Updated Weekly \ud83c\udfa7",
"duration": 325,
"cover_url": "https://i.ytimg.com/vi/fyMgBQioTLo/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3kNXGGAK5wy2ix4mQ1pNwlGLYUg0Q",
"id": "fyMgBQioTLo",
"url": "https://music.youtube.com/watch?v=fyMgBQioTLo"
},
{
"title": "FRI(END)S",
"artist": "V",
"album": "\ud83d\udcbf Vietnam \ud83c\udfa7 TOP 50 \ud83d\udcbf Music Chart \ud83c\udfa7 Full SPOTIFY Video Playlist \ud83d\udcbf Updated Weekly \ud83c\udfa7",
"duration": 254,
"cover_url": "https://i.ytimg.com/vi/62peQdQv4uo/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3kdJHc4aKKdSbjWA95DPVNDZ9eCgA",
"id": "62peQdQv4uo",
"url": "https://music.youtube.com/watch?v=62peQdQv4uo"
},
{
"title": "Ch\u00ecm S\u00e2u (feat. Trung Tr\u1ea7n)",
"artist": "RPT MCK",
"album": "\ud83d\udcbf Vietnam \ud83c\udfa7 TOP 50 \ud83d\udcbf Music Chart \ud83c\udfa7 Full SPOTIFY Video Playlist \ud83d\udcbf Updated Weekly \ud83c\udfa7",
"duration": 159,
"cover_url": "https://i.ytimg.com/vi/Yw9Ra2UiVLw/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3kJVIY2E4nN72epESsrE4a8_0Ybhg",
"id": "Yw9Ra2UiVLw",
"url": "https://music.youtube.com/watch?v=Yw9Ra2UiVLw"
},
{
"title": "WALK",
"artist": "HURRYKNG, HIEUTHUHAI, Negav, Ph\u00e1p Ki\u1ec1u, and Isaac",
"album": "\ud83d\udcbf Vietnam \ud83c\udfa7 TOP 50 \ud83d\udcbf Music Chart \ud83c\udfa7 Full SPOTIFY Video Playlist \ud83d\udcbf Updated Weekly \ud83c\udfa7",
"duration": 349,
"cover_url": "https://i.ytimg.com/vi/iiL1XDZe-JM/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3kPkUKHjm0Yy3CSzyRGycAiIas7Ew",
"id": "iiL1XDZe-JM",
"url": "https://music.youtube.com/watch?v=iiL1XDZe-JM"
},
{
"title": "Ch\u00fang Ta C\u1ee7a Hi\u1ec7n T\u1ea1i",
"artist": "S\u01a1n T\u00f9ng M-TP",
"album": "Ch\u00fang Ta C\u1ee7a Hi\u1ec7n T\u1ea1i",
"duration": 302,
"cover_url": "https://lh3.googleusercontent.com/R96C4cCNuVOuaKpo8AfoM2ienXSOY3rhljcOi2_7Cg1KnjyZ3hr1X_A5Z8G5vOg645yG6P8txcu1r5kI=w120-h120-l90-rj",
"id": "bNp9pn0ni3I",
"url": "https://music.youtube.com/watch?v=bNp9pn0ni3I"
},
{
"title": "Mi\u1ec1n M\u1ed9ng M\u1ecb",
"artist": "AMEE",
"album": "\ud83d\udcbf Vietnam \ud83c\udfa7 TOP 50 \ud83d\udcbf Music Chart \ud83c\udfa7 Full SPOTIFY Video Playlist \ud83d\udcbf Updated Weekly \ud83c\udfa7",
"duration": 167,
"cover_url": "https://i.ytimg.com/vi/8ItcR_8NkP8/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3k60wEuxjklJhJjGOsZHrGHoPDxSw",
"id": "8ItcR_8NkP8",
"url": "https://music.youtube.com/watch?v=8ItcR_8NkP8"
} }
], ],
"type": "playlist" "type": "playlist"

File diff suppressed because it is too large Load diff

View file

@ -1,6 +1,6 @@
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from backend.api import routes from backend.api.routes import router as api_router
import os import os
app = FastAPI(title="Spotify Clone Backend") app = FastAPI(title="Spotify Clone Backend")
@ -19,15 +19,11 @@ app.add_middleware(
allow_headers=["*"], allow_headers=["*"],
) )
app.include_router(routes.router, prefix="/api") app.include_router(api_router, prefix="/api")
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from fastapi.responses import FileResponse from fastapi.responses import FileResponse
# ... existing code ...
app.include_router(routes.router, prefix="/api")
# Serve Static Frontend (Production Mode) # Serve Static Frontend (Production Mode)
STATIC_DIR = "static" STATIC_DIR = "static"
if os.path.exists(STATIC_DIR): if os.path.exists(STATIC_DIR):

View file

@ -1,9 +1,9 @@
fastapi fastapi==0.115.6
uvicorn uvicorn==0.34.0
spotdl spotdl
pydantic pydantic==2.10.4
python-multipart python-multipart==0.0.20
requests requests==2.32.3
yt-dlp yt-dlp==2024.12.23
ytmusicapi ytmusicapi==1.9.1
syncedlyrics syncedlyrics

17
debug_recs.py Normal file
View file

@ -0,0 +1,17 @@
from ytmusicapi import YTMusic
import json
yt = YTMusic()
seed_id = "hDrFd1W8fvU"
print(f"Fetching watch playlist for {seed_id}...")
results = yt.get_watch_playlist(videoId=seed_id, limit=5)
if 'tracks' in results:
print(f"Found {len(results['tracks'])} tracks.")
if len(results['tracks']) > 0:
first_track = results['tracks'][0]
print(json.dumps(first_track, indent=2))
print("Keys:", first_track.keys())
else:
print("No 'tracks' key in results")
print(results.keys())

0
deploy_commands.sh Executable file → Normal file
View file

View file

@ -9,8 +9,10 @@ yt = YTMusic()
# Define diverse categories to fetch # Define diverse categories to fetch
CATEGORIES = { CATEGORIES = {
"Trending Vietnam": {"query": "Top 50 Vietnam", "type": "playlists"}, "Trending Vietnam": {"query": "Top 50 Vietnam", "type": "playlists"},
"Just released Songs": {"query": "New Released Songs", "type": "playlists"},
"Albums": {"query": "New Albums 2024", "type": "albums"},
"Vietnamese DJs": {"query": "Vinahouse Remix", "type": "playlists"},
"Global Hits": {"query": "Global Top 50", "type": "playlists"}, "Global Hits": {"query": "Global Top 50", "type": "playlists"},
"New Albums 2024": {"query": "New Albums 2024 Vietnam", "type": "albums"},
"Chill Vibes": {"query": "Chill Lofi", "type": "playlists"}, "Chill Vibes": {"query": "Chill Lofi", "type": "playlists"},
"Party Time": {"query": "Party EDM Hits", "type": "playlists"}, "Party Time": {"query": "Party EDM Hits", "type": "playlists"},
"Best of Ballad": {"query": "Vietnamese Ballad", "type": "playlists"}, "Best of Ballad": {"query": "Vietnamese Ballad", "type": "playlists"},
@ -32,11 +34,11 @@ for category_name, info in CATEGORIES.items():
print(f"\n--- Fetching Category: {category_name} (Query: '{query}', Type: {search_type}) ---") print(f"\n--- Fetching Category: {category_name} (Query: '{query}', Type: {search_type}) ---")
try: try:
results = yt.search(query, filter=search_type, limit=5) results = yt.search(query, filter=search_type, limit=25)
category_items = [] category_items = []
for result in results[:4]: # Limit to 4 items per category for result in results[:20]: # Limit to 20 items per category
item_id = result['browseId'] item_id = result['browseId']
title = result['title'] title = result['title']
print(f" > Processing: {title}") print(f" > Processing: {title}")

View file

@ -0,0 +1,271 @@
"use client";
import { useEffect, useState, Suspense } from "react";
import { useSearchParams } from "next/navigation";
import { Play, Pause, Clock, User, Music, Plus, ChevronDown, ChevronUp } from "lucide-react";
import { usePlayer } from "@/context/PlayerContext";
import CoverImage from "@/components/CoverImage";
import AddToPlaylistModal from "@/components/AddToPlaylistModal";
import Link from "next/link";
import { Track } from "@/types";
function ArtistPageContent() {
const searchParams = useSearchParams();
const artistName = searchParams.get("name") || "";
const [allSongs, setAllSongs] = useState<Track[]>([]);
const [artistPhoto, setArtistPhoto] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [showAllSongs, setShowAllSongs] = useState(false);
const { playTrack, currentTrack, isPlaying } = usePlayer();
// Modal State
const [isAddToPlaylistOpen, setIsAddToPlaylistOpen] = useState(false);
const [trackToAdd, setTrackToAdd] = useState<Track | null>(null);
useEffect(() => {
if (!artistName) return;
setIsLoading(true);
const apiUrl = process.env.NEXT_PUBLIC_API_URL || '';
// Fetch artist info (photo) and songs in parallel
Promise.all([
fetch(`${apiUrl}/api/artist/info?name=${encodeURIComponent(artistName)}`).then(r => r.json()),
fetch(`${apiUrl}/api/search?query=${encodeURIComponent(artistName)}`).then(r => r.json())
])
.then(([artistInfo, searchData]) => {
if (artistInfo.photo) {
setArtistPhoto(artistInfo.photo);
}
setAllSongs(searchData.tracks || []);
})
.catch(err => console.error("Failed to load artist:", err))
.finally(() => setIsLoading(false));
}, [artistName]);
// Categorize songs
const topSongs = allSongs.slice(0, 5); // Most popular
const hitSongs = allSongs.slice(5, 10); // More hits
const recentSongs = allSongs.slice(10, 15); // Recent-ish
const otherSongs = allSongs.slice(15); // Rest
const handlePlay = (track: Track, queue: Track[]) => {
playTrack(track, queue);
};
const openAddToPlaylist = (e: React.MouseEvent, track: Track) => {
e.stopPropagation();
setTrackToAdd(track);
setIsAddToPlaylistOpen(true);
};
// Get first letter for avatar fallback
const artistInitials = artistName.substring(0, 2).toUpperCase();
if (!artistName) {
return (
<div className="h-full flex items-center justify-center">
<p className="text-[#a7a7a7]">No artist specified</p>
</div>
);
}
return (
<div className="h-full overflow-y-auto bg-gradient-to-b from-[#1e1e1e] to-[#121212] no-scrollbar pb-24">
{/* Artist Header */}
<div className="relative h-80 bg-gradient-to-b from-[#535353] to-transparent p-6 flex items-end">
{/* Artist Avatar - Use real photo or fallback */}
{artistPhoto ? (
<img
src={artistPhoto}
alt={artistName}
className="w-48 h-48 rounded-full object-cover shadow-2xl mr-6"
/>
) : (
<div className="w-48 h-48 rounded-full bg-gradient-to-br from-purple-600 to-blue-500 flex items-center justify-center text-white text-6xl font-bold shadow-2xl mr-6">
{artistInitials}
</div>
)}
<div className="flex-1">
<p className="text-sm font-medium uppercase tracking-widest mb-2 flex items-center gap-2">
<User className="w-4 h-4" /> Artist
</p>
<h1 className="text-5xl md:text-7xl font-bold mb-4">{artistName}</h1>
<p className="text-[#a7a7a7]">{allSongs.length} songs found</p>
</div>
</div>
{/* Play Button */}
<div className="px-6 py-4 flex items-center gap-4">
<button
onClick={() => allSongs.length > 0 && handlePlay(allSongs[0], allSongs)}
className="w-14 h-14 bg-[#1DB954] rounded-full flex items-center justify-center hover:scale-105 transition shadow-lg"
>
<Play className="fill-black text-black ml-1 w-6 h-6" />
</button>
</div>
{isLoading ? (
<div className="px-6 py-8 flex items-center justify-center">
<div className="animate-pulse text-[#a7a7a7]">Loading songs...</div>
</div>
) : allSongs.length === 0 ? (
<div className="px-6 py-8 text-center">
<p className="text-[#a7a7a7]">No songs found for this artist</p>
</div>
) : (
<div className="px-6 space-y-8">
{/* Popular / Top Songs */}
{topSongs.length > 0 && (
<section>
<div className="flex items-center gap-2 mb-4">
<Music className="w-5 h-5 text-[#1DB954]" />
<h2 className="text-2xl font-bold">Popular</h2>
</div>
<SongList songs={topSongs} allSongs={allSongs} onPlay={handlePlay} onAddToPlaylist={openAddToPlaylist} currentTrack={currentTrack} isPlaying={isPlaying} />
</section>
)}
{/* Hit Songs */}
{hitSongs.length > 0 && (
<section>
<div className="flex items-center gap-2 mb-4">
<span className="text-xl">🔥</span>
<h2 className="text-2xl font-bold">Hits</h2>
</div>
<SongList songs={hitSongs} allSongs={allSongs} onPlay={handlePlay} onAddToPlaylist={openAddToPlaylist} currentTrack={currentTrack} isPlaying={isPlaying} />
</section>
)}
{/* Recent Releases */}
{recentSongs.length > 0 && (
<section>
<div className="flex items-center gap-2 mb-4">
<Clock className="w-5 h-5 text-[#1DB954]" />
<h2 className="text-2xl font-bold">More Songs</h2>
</div>
<SongList songs={recentSongs} allSongs={allSongs} onPlay={handlePlay} onAddToPlaylist={openAddToPlaylist} currentTrack={currentTrack} isPlaying={isPlaying} />
</section>
)}
{/* All Other Songs */}
{otherSongs.length > 0 && (
<section>
<button
onClick={() => setShowAllSongs(!showAllSongs)}
className="flex items-center gap-2 mb-4 text-2xl font-bold hover:underline"
>
<span>All Songs ({otherSongs.length})</span>
{showAllSongs ? <ChevronUp className="w-6 h-6" /> : <ChevronDown className="w-6 h-6" />}
</button>
{showAllSongs && (
<SongList songs={otherSongs} allSongs={allSongs} onPlay={handlePlay} onAddToPlaylist={openAddToPlaylist} currentTrack={currentTrack} isPlaying={isPlaying} />
)}
</section>
)}
</div>
)}
<AddToPlaylistModal
track={trackToAdd}
isOpen={isAddToPlaylistOpen}
onClose={() => setIsAddToPlaylistOpen(false)}
/>
</div>
);
}
// Song List Component
interface SongListProps {
songs: Track[];
allSongs: Track[];
onPlay: (track: Track, queue: Track[]) => void;
onAddToPlaylist: (e: React.MouseEvent, track: Track) => void;
currentTrack: Track | null;
isPlaying: boolean;
}
function SongList({ songs, allSongs, onPlay, onAddToPlaylist, currentTrack, isPlaying }: SongListProps) {
return (
<div className="space-y-2">
{songs.map((track, index) => {
const isCurrent = currentTrack?.id === track.id;
return (
<div
key={`${track.id}-${index}`}
onClick={() => onPlay(track, allSongs)}
className={`flex items-center gap-4 p-3 rounded-md hover:bg-[#282828] cursor-pointer group transition ${isCurrent ? 'bg-[#282828]' : ''}`}
>
{/* Track Number / Play Icon */}
<div className="w-8 text-center text-[#a7a7a7] group-hover:hidden">
{isCurrent ? (
<span className="text-[#1DB954]">{isPlaying ? '▶' : '❚❚'}</span>
) : (
<span>{index + 1}</span>
)}
</div>
<div className="w-8 text-center hidden group-hover:block">
{isCurrent && isPlaying ? (
<Pause className="w-4 h-4 text-white mx-auto" />
) : (
<Play className="w-4 h-4 text-white mx-auto" />
)}
</div>
{/* Cover */}
<div className="w-12 h-12 flex-shrink-0">
<CoverImage
src={track.cover_url}
alt={track.title}
className="w-12 h-12 rounded object-cover"
/>
</div>
{/* Track Info */}
<div className="flex-1 min-w-0">
<h3 className={`font-medium truncate ${isCurrent ? 'text-[#1DB954]' : 'text-white'}`}>
{track.title}
</h3>
<p className="text-sm text-[#a7a7a7] truncate">{track.artist}</p>
</div>
{/* Album */}
<div className="hidden md:block flex-1 min-w-0">
<p className="text-sm text-[#a7a7a7] truncate">{track.album}</p>
</div>
{/* Duration */}
<div className="text-sm text-[#a7a7a7] w-12 text-right">
{track.duration ? formatDuration(track.duration) : '--:--'}
</div>
{/* Add to Playlist */}
<button
onClick={(e) => onAddToPlaylist(e, track)}
className="p-2 opacity-0 group-hover:opacity-100 hover:bg-[#383838] rounded-full transition"
>
<Plus className="w-4 h-4 text-white" />
</button>
</div>
);
})}
</div>
);
}
function formatDuration(seconds: number): string {
const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
return `${mins}:${secs.toString().padStart(2, '0')}`;
}
export default function ArtistPage() {
return (
<Suspense fallback={<div className="h-full flex items-center justify-center text-[#a7a7a7]">Loading...</div>}>
<ArtistPageContent />
</Suspense>
);
}

View file

@ -34,4 +34,38 @@ body {
background: var(--spotify-base); background: var(--spotify-base);
color: var(--spotify-text-main); color: var(--spotify-text-main);
/* font-family set in layout via className */ /* font-family set in layout via className */
} }
/* Soundwave Animations for Logo */
@keyframes soundwave-1 {
0%, 100% { height: 12px; }
50% { height: 8px; }
}
@keyframes soundwave-2 {
0%, 100% { height: 20px; }
50% { height: 10px; }
}
@keyframes soundwave-3 {
0%, 100% { height: 16px; }
50% { height: 6px; }
}
@keyframes soundwave-4 {
0%, 100% { height: 8px; }
50% { height: 14px; }
}
.animate-soundwave-1 { animation: soundwave-1 0.8s ease-in-out infinite; }
.animate-soundwave-2 { animation: soundwave-2 0.6s ease-in-out infinite; }
.animate-soundwave-3 { animation: soundwave-3 0.7s ease-in-out infinite; }
.animate-soundwave-4 { animation: soundwave-4 0.9s ease-in-out infinite; }
/* Fade-in animation */
@keyframes fadeIn {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
.animate-in { animation: fadeIn 0.5s ease-out forwards; }

View file

@ -4,6 +4,7 @@ import "./globals.css";
import Sidebar from "@/components/Sidebar"; import Sidebar from "@/components/Sidebar";
import PlayerBar from "@/components/PlayerBar"; import PlayerBar from "@/components/PlayerBar";
import MobileNav from "@/components/MobileNav"; import MobileNav from "@/components/MobileNav";
import RightSidebar from "@/components/RightSidebar";
import { PlayerProvider } from "@/context/PlayerContext"; import { PlayerProvider } from "@/context/PlayerContext";
import { LibraryProvider } from "@/context/LibraryContext"; import { LibraryProvider } from "@/context/LibraryContext";
@ -46,6 +47,7 @@ export default function RootLayout({
<main className="flex-1 bg-[#121212] rounded-lg overflow-y-auto relative no-scrollbar"> <main className="flex-1 bg-[#121212] rounded-lg overflow-y-auto relative no-scrollbar">
{children} {children}
</main> </main>
<RightSidebar />
</div> </div>
<PlayerBar /> <PlayerBar />
<MobileNav /> <MobileNav />

View file

@ -6,6 +6,7 @@ import { useLibrary } from "@/context/LibraryContext";
import Link from "next/link"; import Link from "next/link";
import { Plus } from "lucide-react"; import { Plus } from "lucide-react";
import CreatePlaylistModal from "@/components/CreatePlaylistModal"; import CreatePlaylistModal from "@/components/CreatePlaylistModal";
import CoverImage from "@/components/CoverImage";
export default function LibraryPage() { export default function LibraryPage() {
const { userPlaylists: playlists, libraryItems, refreshLibrary: refresh, activeFilter: activeTab, setActiveFilter: setActiveTab } = useLibrary(); const { userPlaylists: playlists, libraryItems, refreshLibrary: refresh, activeFilter: activeTab, setActiveFilter: setActiveTab } = useLibrary();
@ -61,7 +62,7 @@ export default function LibraryPage() {
</button> </button>
</div> </div>
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4"> <div className="grid grid-cols-3 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-3 md:gap-4">
{/* Playlists & Liked Songs */} {/* Playlists & Liked Songs */}
{showPlaylists && ( {showPlaylists && (
<> <>
@ -74,36 +75,34 @@ export default function LibraryPage() {
{playlists.map((playlist) => ( {playlists.map((playlist) => (
<Link href={`/playlist?id=${playlist.id}`} key={playlist.id}> <Link href={`/playlist?id=${playlist.id}`} key={playlist.id}>
<div className="bg-[#181818] p-3 rounded-md hover:bg-[#282828] transition aspect-[3/4] flex flex-col"> <div className="bg-[#181818] p-2 md:p-3 rounded-md hover:bg-[#282828] transition aspect-[3/4] flex flex-col">
<div className="aspect-square w-full mb-3 overflow-hidden rounded-md bg-[#282828] shadow-lg"> <div className="aspect-square w-full mb-2 md:mb-3 overflow-hidden rounded-md shadow-lg">
{playlist.cover_url && !playlist.cover_url.includes("placehold") ? ( <CoverImage
<img src={playlist.cover_url} alt={playlist.title} className="w-full h-full object-cover" /> src={playlist.cover_url}
) : ( alt={playlist.title}
<div className="w-full h-full flex items-center justify-center bg-[#333]"> className="w-full h-full object-cover"
<span className="text-2xl">🎵</span> fallbackText={playlist.title?.substring(0, 2).toUpperCase()}
</div> />
)}
</div> </div>
<h3 className="text-white font-bold text-sm truncate">{playlist.title}</h3> <h3 className="text-white font-bold text-xs md:text-sm truncate">{playlist.title}</h3>
<p className="text-[#a7a7a7] text-xs">Playlist You</p> <p className="text-[#a7a7a7] text-[10px] md:text-xs">Playlist You</p>
</div> </div>
</Link> </Link>
))} ))}
{browsePlaylists.map((playlist) => ( {browsePlaylists.map((playlist) => (
<Link href={`/playlist?id=${playlist.id}`} key={playlist.id}> <Link href={`/playlist?id=${playlist.id}`} key={playlist.id}>
<div className="bg-[#181818] p-3 rounded-md hover:bg-[#282828] transition aspect-[3/4] flex flex-col"> <div className="bg-[#181818] p-2 md:p-3 rounded-md hover:bg-[#282828] transition aspect-[3/4] flex flex-col">
<div className="aspect-square w-full mb-3 overflow-hidden rounded-md bg-[#282828] shadow-lg"> <div className="aspect-square w-full mb-2 md:mb-3 overflow-hidden rounded-md shadow-lg">
{playlist.cover_url && !playlist.cover_url.includes("placehold") ? ( <CoverImage
<img src={playlist.cover_url} alt={playlist.title} className="w-full h-full object-cover" /> src={playlist.cover_url}
) : ( alt={playlist.title}
<div className="w-full h-full flex items-center justify-center bg-[#333]"> className="w-full h-full object-cover"
<span className="text-2xl">🎵</span> fallbackText={playlist.title?.substring(0, 2).toUpperCase()}
</div> />
)}
</div> </div>
<h3 className="text-white font-bold text-sm truncate">{playlist.title}</h3> <h3 className="text-white font-bold text-xs md:text-sm truncate">{playlist.title}</h3>
<p className="text-[#a7a7a7] text-xs">Playlist Made for you</p> <p className="text-[#a7a7a7] text-[10px] md:text-xs">Playlist Made for you</p>
</div> </div>
</Link> </Link>
))} ))}
@ -112,27 +111,18 @@ export default function LibraryPage() {
{/* Artists Content (Circular Images) */} {/* Artists Content (Circular Images) */}
{showArtists && artists.map((artist) => ( {showArtists && artists.map((artist) => (
<Link href={`/playlist?id=${artist.id}`} key={artist.id}> <Link href={`/artist?name=${encodeURIComponent(artist.title)}`} key={artist.id}>
<div className="bg-[#181818] p-3 rounded-md hover:bg-[#282828] transition aspect-[3/4] flex flex-col items-center text-center"> <div className="bg-[#181818] p-2 md:p-3 rounded-md hover:bg-[#282828] transition aspect-[3/4] flex flex-col items-center text-center">
<div className="aspect-square w-full mb-3 overflow-hidden rounded-full bg-[#282828] shadow-lg relative"> <div className="aspect-square w-full mb-2 md:mb-3 overflow-hidden rounded-full shadow-lg">
{artist.cover_url ? ( <CoverImage
<img src={artist.cover_url}
src={artist.cover_url} alt={artist.title}
alt={artist.title} className="w-full h-full object-cover rounded-full"
className="w-full h-full object-cover" fallbackText={artist.title?.substring(0, 2).toUpperCase()}
onError={(e) => { />
e.currentTarget.onerror = null; // Prevent infinite loop
e.currentTarget.style.display = 'none';
e.currentTarget.parentElement?.classList.add('bg-[#333]');
}}
/>
) : null}
<div className="absolute inset-0 flex items-center justify-center bg-[#333] -z-10">
<span className="text-2xl">🎤</span>
</div>
</div> </div>
<h3 className="text-white font-bold text-sm truncate w-full">{artist.title}</h3> <h3 className="text-white font-bold text-xs md:text-sm truncate w-full">{artist.title}</h3>
<p className="text-[#a7a7a7] text-xs">Artist</p> <p className="text-[#a7a7a7] text-[10px] md:text-xs">Artist</p>
</div> </div>
</Link> </Link>
))} ))}
@ -140,28 +130,17 @@ export default function LibraryPage() {
{/* Albums Content */} {/* Albums Content */}
{showAlbums && albums.map((album) => ( {showAlbums && albums.map((album) => (
<Link href={`/playlist?id=${album.id}`} key={album.id}> <Link href={`/playlist?id=${album.id}`} key={album.id}>
<div className="bg-[#181818] p-3 rounded-md hover:bg-[#282828] transition aspect-[3/4] flex flex-col"> <div className="bg-[#181818] p-2 md:p-3 rounded-md hover:bg-[#282828] transition aspect-[3/4] flex flex-col">
<div className="aspect-square w-full mb-3 overflow-hidden rounded-md bg-[#282828] shadow-lg relative"> <div className="aspect-square w-full mb-2 md:mb-3 overflow-hidden rounded-md shadow-lg">
{album.cover_url ? ( <CoverImage
<img src={album.cover_url}
src={album.cover_url} alt={album.title}
alt={album.title} className="w-full h-full object-cover"
className="w-full h-full object-cover" fallbackText={album.title?.substring(0, 2).toUpperCase()}
onError={(e) => { />
e.currentTarget.onerror = null; // Prevent infinite loop
e.currentTarget.style.display = 'none'; // Hide broken image
e.currentTarget.parentElement?.classList.add('bg-[#333]'); // add background
// Show fallback icon sibling if possible, distinct from React state
}}
/>
) : null}
{/* Fallback overlay (shown if image missing or hidden via CSS logic would need state, but simpler: just render icon behind it or use state) */}
<div className="absolute inset-0 flex items-center justify-center bg-[#333] -z-10">
<span className="text-2xl">💿</span>
</div>
</div> </div>
<h3 className="text-white font-bold text-sm truncate">{album.title}</h3> <h3 className="text-white font-bold text-xs md:text-sm truncate">{album.title}</h3>
<p className="text-[#a7a7a7] text-xs">Album {album.creator || 'Spotify'}</p> <p className="text-[#a7a7a7] text-[10px] md:text-xs">Album {album.creator || 'Spotify'}</p>
</div> </div>
</Link> </Link>
))} ))}

View file

@ -1,14 +1,22 @@
"use client"; "use client";
import { Play, Pause } from "lucide-react"; import { Play, Pause, ArrowUpDown, Clock, Music2, User } from "lucide-react";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { usePlayer } from "@/context/PlayerContext"; import { usePlayer } from "@/context/PlayerContext";
import Link from "next/link"; import Link from "next/link";
import { libraryService } from "@/services/library"; import { libraryService } from "@/services/library";
import { Track } from "@/types";
import CoverImage from "@/components/CoverImage";
import Skeleton from "@/components/Skeleton";
type SortOption = 'recent' | 'alpha-asc' | 'alpha-desc' | 'artist';
export default function Home() { export default function Home() {
const [timeOfDay, setTimeOfDay] = useState("Good evening"); const [timeOfDay, setTimeOfDay] = useState("Good evening");
const [browseData, setBrowseData] = useState<Record<string, any[]>>({}); const [browseData, setBrowseData] = useState<Record<string, any[]>>({});
const [loading, setLoading] = useState(true);
const [sortBy, setSortBy] = useState<SortOption>('recent');
const [showSortMenu, setShowSortMenu] = useState(false);
useEffect(() => { useEffect(() => {
const hour = new Date().getHours(); const hour = new Date().getHours();
@ -17,120 +25,234 @@ export default function Home() {
else setTimeOfDay("Good evening"); else setTimeOfDay("Good evening");
// Fetch Browse Content // Fetch Browse Content
setLoading(true);
libraryService.getBrowseContent() libraryService.getBrowseContent()
.then(data => setBrowseData(data)) .then(data => {
.catch(err => console.error("Error fetching browse:", err)); setBrowseData(data);
setLoading(false);
})
.catch(err => {
console.error("Error fetching browse:", err);
setLoading(false);
});
}, []); }, []);
// Sort playlists based on selected option
const sortPlaylists = (playlists: any[]) => {
const sorted = [...playlists];
switch (sortBy) {
case 'alpha-asc':
return sorted.sort((a, b) => (a.title || '').localeCompare(b.title || ''));
case 'alpha-desc':
return sorted.sort((a, b) => (b.title || '').localeCompare(a.title || ''));
case 'artist':
return sorted.sort((a, b) => (a.author || a.creator || '').localeCompare(b.author || b.creator || ''));
case 'recent':
default:
return sorted;
}
};
// Use first item of first category as Hero // Use first item of first category as Hero
const firstCategory = Object.keys(browseData)[0]; const firstCategory = Object.keys(browseData)[0];
const heroPlaylist = firstCategory && browseData[firstCategory].length > 0 ? browseData[firstCategory][0] : null; const heroPlaylist = firstCategory && browseData[firstCategory].length > 0 ? browseData[firstCategory][0] : null;
const sortOptions = [
{ value: 'recent', label: 'Recently Added', icon: Clock },
{ value: 'alpha-asc', label: 'Alphabetical (A-Z)', icon: ArrowUpDown },
{ value: 'alpha-desc', label: 'Alphabetical (Z-A)', icon: ArrowUpDown },
{ value: 'artist', label: 'Artist Name', icon: User },
];
return ( return (
<div className="h-full overflow-y-auto bg-gradient-to-b from-[#1e1e1e] to-[#121212] p-6 no-scrollbar pb-24"> <div className="h-full overflow-y-auto bg-gradient-to-b from-[#1e1e1e] to-[#121212] p-6 no-scrollbar pb-24">
{/* Header / Greetings */} {/* Header / Greetings with Sort Button */}
<div className="flex items-center justify-between mb-6"> <div className="flex items-center justify-between mb-6">
<h1 className="text-3xl font-bold">{timeOfDay}</h1> <h1 className="text-3xl font-bold">{timeOfDay}</h1>
{/* Sort Dropdown */}
<div className="relative">
<button
onClick={() => setShowSortMenu(!showSortMenu)}
className="flex items-center gap-2 px-4 py-2 bg-[#282828] hover:bg-[#3a3a3a] rounded-full text-sm font-medium transition"
>
<ArrowUpDown className="w-4 h-4" />
Sort
</button>
{showSortMenu && (
<div className="absolute right-0 mt-2 w-48 bg-[#282828] rounded-lg shadow-xl z-50 py-1 border border-[#383838]">
{sortOptions.map((option) => (
<button
key={option.value}
onClick={() => {
setSortBy(option.value as SortOption);
setShowSortMenu(false);
}}
className={`w-full px-4 py-2 text-left text-sm flex items-center gap-2 hover:bg-[#3a3a3a] transition ${sortBy === option.value ? 'text-[#1DB954]' : 'text-white'}`}
>
<option.icon className="w-4 h-4" />
{option.label}
{sortBy === option.value && (
<span className="ml-auto text-[#1DB954]"></span>
)}
</button>
))}
</div>
)}
</div>
</div> </div>
{/* Hero Section (First Playlist) */} {/* Hero Section (Big Playlist Banner - AT THE TOP) */}
{heroPlaylist && ( {loading ? (
<div className="mb-8 w-full h-80 bg-[#181818] rounded-xl flex items-center p-8 animate-pulse">
<Skeleton className="w-56 h-56 rounded-md shadow-2xl mr-8" />
<div className="flex-1 space-y-4">
<Skeleton className="h-6 w-32" />
<Skeleton className="h-12 w-3/4" />
<Skeleton className="h-4 w-1/2" />
</div>
</div>
) : heroPlaylist && (
<Link href={`/playlist?id=${heroPlaylist.id}`}> <Link href={`/playlist?id=${heroPlaylist.id}`}>
<div className="flex flex-col md:flex-row gap-6 mb-8 hover:bg-white/10 p-4 rounded-md transition group cursor-pointer items-center md:items-end text-center md:text-left"> <div className="mb-8 w-full h-auto md:h-80 bg-gradient-to-r from-[#2a2a2a] to-[#181818] rounded-xl flex flex-col md:flex-row items-center p-6 md:p-8 hover:bg-[#2a2a2a] transition duration-300 group cursor-pointer shadow-2xl">
<img <div className="relative mb-4 md:mb-0 md:mr-8 flex-shrink-0">
src={heroPlaylist.cover_url || "https://placehold.co/200"} <CoverImage
alt={heroPlaylist.title} src={heroPlaylist.cover_url}
className="w-48 h-48 md:w-60 md:h-60 shadow-2xl object-cover rounded-md" alt={heroPlaylist.title}
/> className="w-48 h-48 md:w-56 md:h-56 object-cover rounded-md shadow-2xl group-hover:scale-105 transition duration-500"
<div className="flex flex-col justify-end w-full"> fallbackText="VB"
<p className="text-xs font-bold uppercase tracking-widest mb-2 hidden md:block">Playlist</p> />
<h2 className="text-3xl md:text-6xl font-bold mb-2 md:mb-4 line-clamp-2">{heroPlaylist.title}</h2> </div>
<p className="text-sm font-medium mb-4 text-[#a7a7a7] line-clamp-2">{heroPlaylist.description}</p> <div className="flex flex-col text-center md:text-left">
<div className="flex items-center justify-center md:justify-start gap-4"> <span className="text-xs font-bold tracking-wider uppercase mb-2">Featured Playlist</span>
<div className="w-12 h-12 md:w-14 md:h-14 bg-[#1DB954] rounded-full flex items-center justify-center hover:scale-105 transition shadow-lg"> <h2 className="text-3xl md:text-5xl font-black mb-4 leading-tight">{heroPlaylist.title}</h2>
<Play className="fill-black text-black ml-1 w-5 h-5 md:w-6 md:h-6" /> <p className="text-[#a7a7a7] text-sm md:text-base line-clamp-2 md:line-clamp-3 max-w-2xl mb-6">
</div> {heroPlaylist.description}
</p>
<div className="mt-auto inline-flex items-center gap-2 bg-[#1DB954] text-black px-8 py-3 rounded-full font-bold uppercase tracking-widest hover:scale-105 transition self-center md:self-start">
<Play className="fill-black" />
Play Now
</div> </div>
</div> </div>
</div> </div>
</Link> </Link>
)} )}
{/* Made For You (Recommendations) */} {/* Made For You Section (Recommendations) */}
<MadeForYouSection /> <MadeForYouSection />
{/* Recommended Albums */} {/* Artist Section (Vietnam) */}
<ArtistVietnamSection />
{/* Dynamic Recommended Albums based on history */}
<RecommendedAlbumsSection /> <RecommendedAlbumsSection />
{/* Render Categories */} {/* Recently Listened */}
{Object.entries(browseData).map(([category, playlists]) => ( <RecentlyListenedSection />
<div key={category} className="mb-8">
<h2 className="text-2xl font-bold mb-4 hover:underline cursor-pointer">{category}</h2> {/* Main Browse Lists */}
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-6"> {loading ? (
{playlists.slice(0, 5).map((playlist) => ( <div className="space-y-8">
<Link href={`/playlist?id=${playlist.id}`} key={playlist.id}> {[1, 2].map(i => (
<div className="bg-[#181818] p-4 rounded-md hover:bg-[#282828] transition duration-300 group cursor-pointer relative h-full flex flex-col"> <div key={i}>
<div className="relative mb-4"> <Skeleton className="h-8 w-48 mb-4" />
<img <div className="grid grid-cols-3 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-6">
src={playlist.cover_url || "https://placehold.co/200"} {[1, 2, 3, 4, 5].map(j => (
alt={playlist.title} <div key={j} className="space-y-3">
className="w-full aspect-square object-cover rounded-md shadow-lg" <Skeleton className="w-full aspect-square rounded-md" />
/> <Skeleton className="h-4 w-3/4" />
<div className="absolute bottom-2 right-2 translate-y-4 opacity-0 group-hover:translate-y-0 group-hover:opacity-100 transition duration-300 shadow-xl"> <Skeleton className="h-3 w-1/2" />
<div className="w-12 h-12 bg-[#1DB954] rounded-full flex items-center justify-center hover:scale-105"> </div>
<Play className="fill-black text-black ml-1" /> ))}
</div>
</div>
))}
</div>
) : Object.keys(browseData).length > 0 ? (
Object.entries(browseData).map(([category, playlists]) => (
<div key={category} className="mb-8">
<div className="flex items-center justify-between mb-4">
<h2 className="text-2xl font-bold capitalize hover:underline cursor-pointer">{category}</h2>
<Link href={`/section?category=${encodeURIComponent(category)}`}>
<span className="text-xs font-bold text-[#b3b3b3] uppercase tracking-wider hover:text-white cursor-pointer">Show all</span>
</Link>
</div>
<div className="grid grid-cols-3 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-6">
{sortPlaylists(playlists).slice(0, 5).map((playlist: any) => (
<Link href={`/playlist?id=${playlist.id}`} key={playlist.id}>
<div className="bg-[#181818] p-4 rounded-md hover:bg-[#282828] transition duration-300 group cursor-pointer relative h-full flex flex-col">
<div className="relative mb-4">
<CoverImage
src={playlist.cover_url}
alt={playlist.title}
className="w-full aspect-square object-cover rounded-md shadow-lg"
fallbackText={playlist.title.substring(0, 2).toUpperCase()}
/>
<div className="absolute bottom-2 right-2 translate-y-4 opacity-0 group-hover:translate-y-0 group-hover:opacity-100 transition duration-300 shadow-xl">
<div className="w-12 h-12 bg-[#1DB954] rounded-full flex items-center justify-center hover:scale-105">
<Play className="fill-black text-black ml-1" />
</div>
</div> </div>
</div> </div>
<h3 className="font-bold mb-1 truncate">{playlist.title}</h3>
<p className="text-sm text-[#a7a7a7] line-clamp-2">{playlist.description}</p>
</div> </div>
<h3 className="font-bold mb-1 truncate">{playlist.title}</h3> </Link>
<p className="text-sm text-[#a7a7a7] line-clamp-2">{playlist.description}</p> ))}
</div> </div>
</Link>
))}
</div> </div>
))
) : (
<div className="text-center py-20">
<h2 className="text-xl font-bold mb-4">Ready to explore?</h2>
<p className="text-[#a7a7a7]">Browse content is loading or empty. Try initializing data.</p>
</div> </div>
))} )}
</div> </div>
); );
} }
function MadeForYouSection() { // NEW: Recently Listened Section - Pinned to top
function RecentlyListenedSection() {
const { playHistory, playTrack } = usePlayer(); const { playHistory, playTrack } = usePlayer();
const [recommendations, setRecommendations] = useState<any[]>([]);
useEffect(() => { if (playHistory.length === 0) return null;
if (playHistory.length > 0) {
const seed = playHistory[0]; // Last played
libraryService.getRecommendations(seed.id)
.then(tracks => setRecommendations(tracks))
.catch(err => console.error("Rec error:", err));
}
}, [playHistory.length > 0 ? playHistory[0].id : null]);
if (playHistory.length === 0 || recommendations.length === 0) return null;
return ( return (
<div className="mb-8 animate-in fade-in duration-500"> <div className="mb-8 animate-in fade-in duration-300">
<h2 className="text-2xl font-bold mb-4">Made For You</h2> <div className="flex items-center gap-2 mb-4">
<p className="text-sm text-[#a7a7a7] mb-4">Based on your listening of <span className="text-white font-medium">{playHistory[0].title}</span></p> <Clock className="w-5 h-5 text-[#1DB954]" />
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-6"> <h2 className="text-2xl font-bold">Recently Listened</h2>
{recommendations.slice(0, 5).map((track, i) => ( </div>
<div key={i} onClick={() => playTrack(track, recommendations)} className="bg-[#181818] p-4 rounded-md hover:bg-[#282828] transition duration-300 group cursor-pointer relative h-full flex flex-col">
<div className="relative mb-4"> {/* Horizontal Scrollable Row */}
<img <div className="flex gap-4 overflow-x-auto pb-4 no-scrollbar">
{playHistory.slice(0, 10).map((track, i) => (
<div
key={`${track.id}-${i}`}
onClick={() => playTrack(track, playHistory)}
className="flex-shrink-0 w-40 bg-[#181818] rounded-lg overflow-hidden hover:bg-[#282828] transition duration-300 group cursor-pointer"
>
<div className="relative">
<CoverImage
src={track.cover_url} src={track.cover_url}
alt={track.title} alt={track.title}
className="w-full aspect-square object-cover rounded-md shadow-lg" className="w-40 h-40 object-cover"
fallbackText={track.title?.substring(0, 2).toUpperCase()}
/> />
<div className="absolute bottom-2 right-2 translate-y-4 opacity-0 group-hover:translate-y-0 group-hover:opacity-100 transition duration-300 shadow-xl"> <div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition flex items-center justify-center">
<div className="w-12 h-12 bg-[#1DB954] rounded-full flex items-center justify-center hover:scale-105"> <div className="w-12 h-12 bg-[#1DB954] rounded-full flex items-center justify-center shadow-lg transform scale-90 group-hover:scale-100 transition">
<Play className="fill-black text-black ml-1" /> <Play className="fill-black text-black ml-1 w-5 h-5" />
</div> </div>
</div> </div>
</div> </div>
<h3 className="font-bold mb-1 truncate">{track.title}</h3> <div className="p-3">
<p className="text-sm text-[#a7a7a7] line-clamp-2">{track.artist}</p> <h3 className="font-medium text-sm truncate">{track.title}</h3>
<p className="text-xs text-[#a7a7a7] truncate">{track.artist}</p>
</div>
</div> </div>
))} ))}
</div> </div>
@ -138,40 +260,69 @@ function MadeForYouSection() {
); );
} }
function RecommendedAlbumsSection() { function MadeForYouSection() {
const { playHistory } = usePlayer(); const { playHistory, playTrack } = usePlayer();
const [albums, setAlbums] = useState<any[]>([]); const [recommendations, setRecommendations] = useState<Track[]>([]);
const [seedTrack, setSeedTrack] = useState<Track | null>(null);
const [loading, setLoading] = useState(false);
useEffect(() => { useEffect(() => {
if (playHistory.length > 0) { if (playHistory.length > 0) {
const seedArtist = playHistory[0].artist; // Last played artist const seed = playHistory[0]; // Last played
if (!seedArtist) return; setSeedTrack(seed);
setLoading(true);
// Clean artist name (remove delimiters like commas if multiple) // Fetch actual recommendations from backend
const primaryArtist = seedArtist.split(',')[0].trim(); fetch(`/api/recommendations?seed_id=${seed.id}`)
.then(res => res.json())
libraryService.getRecommendedAlbums(primaryArtist)
.then(data => { .then(data => {
if (Array.isArray(data)) setAlbums(data); if (data.tracks) {
setRecommendations(data.tracks);
}
setLoading(false);
}) })
.catch(err => console.error("Album Rec error:", err)); .catch(err => {
console.error("Rec error:", err);
setLoading(false);
});
} }
}, [playHistory.length > 0 ? playHistory[0].artist : null]); }, [playHistory.length > 0 ? playHistory[0].id : null]);
if (playHistory.length === 0 || albums.length === 0) return null; if (playHistory.length === 0) return null;
if (!loading && recommendations.length === 0) return null;
return ( return (
<div className="mb-8 animate-in fade-in duration-700"> <div className="mb-8 animate-in fade-in duration-500">
<h2 className="text-2xl font-bold mb-4">Recommended Albums</h2> <div className="flex items-center gap-2 mb-2">
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-6"> <Music2 className="w-5 h-5 text-[#1DB954]" />
{albums.slice(0, 5).map((album, i) => ( <h2 className="text-2xl font-bold">Made For You</h2>
<Link href={`/playlist?id=${album.id}`} key={i}> </div>
<div className="bg-[#181818] p-4 rounded-md hover:bg-[#282828] transition duration-300 group cursor-pointer relative h-full flex flex-col"> <p className="text-sm text-[#a7a7a7] mb-4">
{seedTrack ? (
<>Because you listened to <span className="text-white font-medium">{seedTrack.artist}</span></>
) : "Recommended for you"}
</p>
{loading ? (
<div className="grid grid-cols-3 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-6">
{[1, 2, 3, 4, 5].map(i => (
<div key={i} className="space-y-3">
<Skeleton className="w-full aspect-square rounded-md" />
<Skeleton className="h-4 w-3/4" />
<Skeleton className="h-3 w-1/2" />
</div>
))}
</div>
) : (
<div className="grid grid-cols-3 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-6">
{recommendations.slice(0, 5).map((track, i) => (
<div key={i} onClick={() => playTrack(track, recommendations)} className="bg-[#181818] p-4 rounded-md hover:bg-[#282828] transition duration-300 group cursor-pointer relative h-full flex flex-col">
<div className="relative mb-4"> <div className="relative mb-4">
<img <CoverImage
src={album.cover_url} src={track.cover_url}
alt={album.title} alt={track.title}
className="w-full aspect-square object-cover rounded-md shadow-lg" className="w-full aspect-square object-cover rounded-md shadow-lg"
fallbackText={track.title?.substring(0, 2).toUpperCase()}
/> />
<div className="absolute bottom-2 right-2 translate-y-4 opacity-0 group-hover:translate-y-0 group-hover:opacity-100 transition duration-300 shadow-xl"> <div className="absolute bottom-2 right-2 translate-y-4 opacity-0 group-hover:translate-y-0 group-hover:opacity-100 transition duration-300 shadow-xl">
<div className="w-12 h-12 bg-[#1DB954] rounded-full flex items-center justify-center hover:scale-105"> <div className="w-12 h-12 bg-[#1DB954] rounded-full flex items-center justify-center hover:scale-105">
@ -179,12 +330,181 @@ function RecommendedAlbumsSection() {
</div> </div>
</div> </div>
</div> </div>
<h3 className="font-bold mb-1 truncate">{album.title}</h3> <h3 className="font-bold mb-1 truncate">{track.title}</h3>
<p className="text-sm text-[#a7a7a7] line-clamp-2">{album.description}</p> <p className="text-sm text-[#a7a7a7] line-clamp-2">{track.artist}</p>
</div> </div>
</Link> ))}
))} </div>
)}
</div>
);
}
function RecommendedAlbumsSection() {
const { playHistory } = usePlayer();
const [albums, setAlbums] = useState<any[]>([]);
const [seedArtist, setSeedArtist] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
useEffect(() => {
if (playHistory.length > 0) {
const artist = playHistory[0].artist;
if (!artist) return;
// Clean artist name (remove delimiters like commas if multiple)
const primaryArtist = artist.split(',')[0].trim();
setSeedArtist(primaryArtist);
setLoading(true);
fetch(`/api/recommendations/albums?seed_artist=${encodeURIComponent(primaryArtist)}`)
.then(res => res.json())
.then(data => {
if (Array.isArray(data)) setAlbums(data);
setLoading(false);
})
.catch(err => {
console.error("Album Rec error:", err);
setLoading(false);
});
}
}, [playHistory.length > 0 ? playHistory[0].artist : null]);
if (playHistory.length === 0) return null;
if (!loading && albums.length === 0) return null;
return (
<div className="mb-8 animate-in fade-in duration-700">
<h2 className="text-2xl font-bold mb-2">More from {seedArtist}</h2>
<p className="text-sm text-[#a7a7a7] mb-4">Albums you might like</p>
{loading ? (
<div className="grid grid-cols-3 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4 md:gap-6">
{[1, 2, 3, 4, 5].map(i => (
<div key={i} className="space-y-3">
<Skeleton className="w-full aspect-square rounded-md" />
<Skeleton className="h-4 w-3/4" />
<Skeleton className="h-3 w-1/2" />
</div>
))}
</div>
) : (
<div className="grid grid-cols-3 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4 md:gap-6">
{albums.slice(0, 5).map((album, i) => (
<Link href={`/playlist?id=${album.id}`} key={i}>
<div className="bg-[#181818] p-4 rounded-md hover:bg-[#282828] transition duration-300 group cursor-pointer relative h-full flex flex-col">
<div className="relative mb-4">
<CoverImage
src={album.cover_url}
alt={album.title}
className="w-full aspect-square object-cover rounded-md shadow-lg"
/>
<div className="absolute bottom-2 right-2 translate-y-4 opacity-0 group-hover:translate-y-0 group-hover:opacity-100 transition duration-300 shadow-xl">
<div className="w-12 h-12 bg-[#1DB954] rounded-full flex items-center justify-center hover:scale-105">
<Play className="fill-black text-black ml-1" />
</div>
</div>
</div>
<h3 className="font-bold mb-1 truncate">{album.title}</h3>
<p className="text-sm text-[#a7a7a7] line-clamp-2">{album.description}</p>
</div>
</Link>
))}
</div>
)}
</div>
);
}
// NEW: Artist Vietnam Section with dynamic photos
function ArtistVietnamSection() {
// Popular Vietnamese artists
const artistNames = [
"Sơn Tùng M-TP",
"HIEUTHUHAI",
"Đen Vâu",
"Hoàng Dũng",
"Vũ.",
"MONO",
"Tlinh",
"Erik",
];
const [artistPhotos, setArtistPhotos] = useState<Record<string, string>>({});
const [loading, setLoading] = useState(true);
useEffect(() => {
// Fetch artist photos from API
const fetchArtistPhotos = async () => {
setLoading(true);
const apiUrl = process.env.NEXT_PUBLIC_API_URL || '';
const photos: Record<string, string> = {};
await Promise.all(
artistNames.map(async (name) => {
try {
const res = await fetch(`${apiUrl}/api/artist/info?name=${encodeURIComponent(name)}`);
if (res.ok) {
const data = await res.json();
if (data.photo) {
photos[name] = data.photo;
}
}
} catch (e) {
console.error(`Failed to fetch photo for ${name}:`, e);
}
})
);
setArtistPhotos(photos);
setLoading(false);
};
fetchArtistPhotos();
}, []);
return (
<div className="mb-8 animate-in fade-in duration-500">
<div className="flex items-center gap-2 mb-4">
<User className="w-5 h-5 text-[#1DB954]" />
<h2 className="text-2xl font-bold">Artist Vietnam</h2>
</div>
<p className="text-sm text-[#a7a7a7] mb-4">Popular Vietnamese artists</p>
{/* Horizontal Scrollable Row */}
<div className="flex gap-4 overflow-x-auto pb-4 no-scrollbar">
{loading ? (
// Skeleton Row
[1, 2, 3, 4, 5, 6].map(i => (
<div key={i} className="flex-shrink-0 w-36 text-center space-y-3">
<Skeleton className="w-36 h-36 rounded-full" />
<Skeleton className="h-4 w-3/4 mx-auto" />
</div>
))
) : (
artistNames.map((name, i) => (
<Link href={`/artist?name=${encodeURIComponent(name)}`} key={i}>
<div className="flex-shrink-0 w-36 text-center group cursor-pointer">
<div className="relative mb-3">
<CoverImage
src={artistPhotos[name]}
alt={name}
className="w-36 h-36 rounded-full object-cover shadow-lg group-hover:shadow-xl transition"
fallbackText={name.substring(0, 2).toUpperCase()}
/>
<div className="absolute inset-0 bg-black/30 opacity-0 group-hover:opacity-100 transition rounded-full flex items-center justify-center">
<div className="w-12 h-12 bg-[#1DB954] rounded-full flex items-center justify-center shadow-lg transform scale-90 group-hover:scale-100 transition">
<Play className="fill-black text-black ml-1 w-5 h-5" />
</div>
</div>
</div>
<h3 className="font-bold text-sm truncate px-2">{name}</h3>
<p className="text-xs text-[#a7a7a7]">Artist</p>
</div>
</Link>
))
)}
</div> </div>
</div> </div>
); );
} }

View file

@ -0,0 +1,172 @@
"use client";
import { useSearchParams, useRouter } from "next/navigation";
import { useEffect, useState, Suspense } from "react";
import { libraryService } from "@/services/library";
import Link from "next/link";
import CoverImage from "@/components/CoverImage";
import { Play, ArrowLeft } from "lucide-react";
import Skeleton from "@/components/Skeleton";
function SectionContent() {
const searchParams = useSearchParams();
const category = searchParams.get("category");
const router = useRouter();
// Full fetched items from backend
const [allItems, setAllItems] = useState<any[]>([]);
// Items currently displayed (subset for infinite scroll)
const [visibleItems, setVisibleItems] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [hasMore, setHasMore] = useState(true);
const [page, setPage] = useState(1);
const ITEMS_PER_PAGE = 20;
useEffect(() => {
if (!category) {
setLoading(false);
return;
}
setLoading(true);
setPage(1); // Reset page when category changes
setHasMore(true); // Reset hasMore when category changes
setVisibleItems([]); // Clear visible items
setAllItems([]); // Clear all items
// Fetch live data from new endpoint
fetch(`/api/browse/category?name=${encodeURIComponent(category)}`)
.then(res => res.json())
.then(data => {
if (Array.isArray(data)) {
setAllItems(data);
setVisibleItems(data.slice(0, ITEMS_PER_PAGE));
setHasMore(data.length > ITEMS_PER_PAGE);
} else {
setAllItems([]);
setVisibleItems([]);
setHasMore(false);
}
setLoading(false);
})
.catch(err => {
console.error("Error fetching section:", err);
setLoading(false);
setAllItems([]);
setVisibleItems([]);
setHasMore(false);
});
}, [category]);
// Load more function
const loadMore = () => {
if (!hasMore || loading) return;
const nextLimit = (page + 1) * ITEMS_PER_PAGE;
const nextItems = allItems.slice(0, nextLimit);
setVisibleItems(nextItems);
setPage(prev => prev + 1);
if (nextLimit >= allItems.length) {
setHasMore(false);
}
};
// Intersection Observer for infinite scroll trigger
useEffect(() => {
if (!hasMore || loading || allItems.length === 0) return;
const observer = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting) {
loadMore();
}
}, { threshold: 0.1 });
const trigger = document.getElementById('scroll-trigger');
if (trigger) observer.observe(trigger);
return () => observer.disconnect();
}, [hasMore, loading, page, allItems]); // Re-run when pagination state updates
if (!category) {
return (
<div className="flex flex-col items-center justify-center h-full text-[#a7a7a7]">
<p>No category specified.</p>
<button onClick={() => router.back()} className="mt-4 text-white hover:underline">Go Back</button>
</div>
);
}
return (
<div className="h-full overflow-y-auto bg-gradient-to-b from-[#1e1e1e] to-[#121212] p-6 no-scrollbar pb-32">
{/* Header */}
<div className="flex items-center gap-4 mb-8">
<button
onClick={() => router.back()}
className="bg-black/50 hover:bg-black/80 p-2 rounded-full transition"
>
<ArrowLeft className="w-6 h-6 text-white" />
</button>
<h1 className="text-3xl font-bold text-white capitalize">{category}</h1>
</div>
{loading && visibleItems.length === 0 ? (
<div className="grid grid-cols-3 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-6">
{[1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map(i => (
<div key={i} className="space-y-3">
<Skeleton className="w-full aspect-square rounded-md" />
<Skeleton className="h-4 w-3/4" />
<Skeleton className="h-3 w-1/2" />
</div>
))}
</div>
) : visibleItems.length > 0 ? (
<>
<div className="grid grid-cols-3 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-6">
{visibleItems.map((playlist: any) => (
<Link href={`/playlist?id=${playlist.id}`} key={playlist.id}>
<div className="bg-[#181818] p-4 rounded-md hover:bg-[#282828] transition duration-300 group cursor-pointer relative h-full flex flex-col">
<div className="relative mb-4">
<CoverImage
src={playlist.cover_url}
alt={playlist.title}
className="w-full aspect-square object-cover rounded-md shadow-lg"
fallbackText={playlist.title.substring(0, 2).toUpperCase()}
/>
<div className="absolute bottom-2 right-2 translate-y-4 opacity-0 group-hover:translate-y-0 group-hover:opacity-100 transition duration-300 shadow-xl">
<div className="w-12 h-12 bg-[#1DB954] rounded-full flex items-center justify-center hover:scale-105">
<Play className="fill-black text-black ml-1" />
</div>
</div>
</div>
<h3 className="font-bold mb-1 truncate">{playlist.title}</h3>
<p className="text-sm text-[#a7a7a7] line-clamp-2">{playlist.description}</p>
</div>
</Link>
))}
</div>
{/* Scroll Trigger / Loading More Indicator */}
{hasMore && (
<div id="scroll-trigger" className="py-8 flex justify-center">
<div className="w-8 h-8 border-4 border-[#333] border-t-[#1DB954] rounded-full animate-spin"></div>
</div>
)}
</>
) : (
<div className="text-center py-20 text-[#a7a7a7]">
<p>No items found in this category.</p>
</div>
)}
</div>
);
}
export default function SectionPage() {
return (
<Suspense fallback={<div className="p-6 text-white">Loading...</div>}>
<SectionContent />
</Suspense>
);
}

View file

@ -0,0 +1,76 @@
"use client";
import { useState } from "react";
interface CoverImageProps {
src?: string;
alt: string;
className?: string;
fallbackText?: string;
}
// Generate a consistent gradient based on text
function getGradient(text: string): string {
const gradients = [
"from-purple-600 to-blue-500",
"from-pink-500 to-orange-400",
"from-green-500 to-teal-400",
"from-blue-600 to-indigo-500",
"from-red-500 to-pink-500",
"from-yellow-500 to-orange-500",
"from-indigo-500 to-purple-500",
"from-teal-500 to-cyan-400",
];
// Simple hash based on string
let hash = 0;
for (let i = 0; i < text.length; i++) {
hash = ((hash << 5) - hash) + text.charCodeAt(i);
hash = hash & hash;
}
return gradients[Math.abs(hash) % gradients.length];
}
export default function CoverImage({ src, alt, className = "", fallbackText }: CoverImageProps) {
const [hasError, setHasError] = useState(false);
const [isLoading, setIsLoading] = useState(true);
const displayText = fallbackText || alt?.substring(0, 2).toUpperCase() || "♪";
const gradient = getGradient(alt || "default");
// Treat placehold.co URLs and empty/undefined src as "no image"
const isPlaceholderUrl = src?.includes('placehold.co') || src?.includes('placeholder');
if (!src || hasError || isPlaceholderUrl) {
return (
<div
className={`bg-gradient-to-br ${gradient} flex items-center justify-center text-white font-bold text-2xl ${className}`}
>
<span className="opacity-80">{displayText}</span>
</div>
);
}
return (
<>
{isLoading && (
<div
className={`bg-gradient-to-br ${gradient} flex items-center justify-center text-white animate-pulse ${className}`}
>
<span className="opacity-50"></span>
</div>
)}
<img
src={src}
alt={alt}
className={`${className} ${isLoading ? 'hidden' : ''}`}
onLoad={() => setIsLoading(false)}
onError={() => {
setHasError(true);
setIsLoading(false);
}}
/>
</>
);
}

View file

@ -0,0 +1,60 @@
"use client";
import { useEffect, useState } from "react";
export default function Logo() {
const [isAnimating, setIsAnimating] = useState(true);
useEffect(() => {
// Subtle animation on load
const timer = setTimeout(() => setIsAnimating(false), 2000);
return () => clearTimeout(timer);
}, []);
return (
<div className="flex items-center gap-3 group cursor-pointer">
{/* Logo Icon */}
<div className={`relative w-10 h-10 ${isAnimating ? 'animate-pulse' : ''}`}>
{/* Glow Background */}
<div className="absolute inset-0 bg-gradient-to-br from-[#1DB954] via-[#1ed760] to-[#169c46] rounded-xl blur-sm opacity-60 group-hover:opacity-100 transition duration-500" />
{/* Main Logo Container */}
<div className="relative w-10 h-10 bg-gradient-to-br from-[#1DB954] to-[#169c46] rounded-xl flex items-center justify-center shadow-lg group-hover:scale-105 transition duration-300">
{/* Sound Wave Bars */}
<div className="flex items-end gap-[3px] h-5">
<div className="w-[3px] bg-black rounded-full animate-soundwave-1 h-3" />
<div className="w-[3px] bg-black rounded-full animate-soundwave-2 h-5" />
<div className="w-[3px] bg-black rounded-full animate-soundwave-3 h-4" />
<div className="w-[3px] bg-black rounded-full animate-soundwave-4 h-2" />
</div>
</div>
</div>
{/* Text Logo */}
<div className="flex flex-col">
<span className="text-lg font-bold bg-gradient-to-r from-white via-[#1DB954] to-[#1ed760] bg-clip-text text-transparent group-hover:from-[#1DB954] group-hover:to-white transition duration-500">
Audiophile
</span>
<span className="text-[10px] text-[#a7a7a7] tracking-widest uppercase -mt-1">
Web Player
</span>
</div>
</div>
);
}
// Mini version for header/mobile
export function LogoMini() {
return (
<div className="relative w-8 h-8 group cursor-pointer">
<div className="absolute inset-0 bg-gradient-to-br from-[#1DB954] to-[#169c46] rounded-lg blur-sm opacity-50 group-hover:opacity-80 transition" />
<div className="relative w-8 h-8 bg-gradient-to-br from-[#1DB954] to-[#169c46] rounded-lg flex items-center justify-center shadow-lg group-hover:scale-110 transition">
<div className="flex items-end gap-[2px] h-4">
<div className="w-[2px] bg-black rounded-full animate-soundwave-1 h-2" />
<div className="w-[2px] bg-black rounded-full animate-soundwave-2 h-4" />
<div className="w-[2px] bg-black rounded-full animate-soundwave-3 h-3" />
</div>
</div>
</div>
);
}

View file

@ -10,9 +10,10 @@ interface LyricsDetailProps {
currentTime: number; currentTime: number;
onClose: () => void; onClose: () => void;
onSeek?: (time: number) => void; onSeek?: (time: number) => void;
isInSidebar?: boolean;
} }
const LyricsDetail: React.FC<LyricsDetailProps> = ({ track, currentTime, onClose, onSeek }) => { const LyricsDetail: React.FC<LyricsDetailProps> = ({ track, currentTime, onClose, onSeek, isInSidebar = false }) => {
const [lyrics, setLyrics] = useState<Metric[]>([]); const [lyrics, setLyrics] = useState<Metric[]>([]);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const scrollContainerRef = useRef<HTMLDivElement>(null); const scrollContainerRef = useRef<HTMLDivElement>(null);
@ -78,24 +79,26 @@ const LyricsDetail: React.FC<LyricsDetailProps> = ({ track, currentTime, onClose
if (!track) return null; if (!track) return null;
return ( return (
<div className={`absolute inset-0 flex flex-col bg-transparent text-white`}> <div className={`${isInSidebar ? 'relative h-full' : 'absolute inset-0'} flex flex-col bg-transparent text-white`}>
{/* Header */} {/* Header - only show when NOT in sidebar */}
<div className="flex items-center justify-between p-6 bg-gradient-to-b from-black/80 to-transparent z-10"> {!isInSidebar && (
<div className="flex-1 min-w-0"> <div className="flex items-center justify-between p-6 bg-gradient-to-b from-black/80 to-transparent z-10">
<h2 className="text-xl font-bold truncate">Lyrics</h2> <div className="flex-1 min-w-0">
<p className="text-white/60 text-xs truncate uppercase tracking-widest"> <h2 className="text-xl font-bold truncate">Lyrics</h2>
{track.artist} <p className="text-white/60 text-xs truncate uppercase tracking-widest">
</p> {track.artist}
</p>
</div>
<button
onClick={onClose}
className="p-2 bg-white/10 rounded-full hover:bg-white/20 transition backdrop-blur-md"
>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M18 6L6 18M6 6l12 12" />
</svg>
</button>
</div> </div>
<button )}
onClick={onClose}
className="p-2 bg-white/10 rounded-full hover:bg-white/20 transition backdrop-blur-md"
>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M18 6L6 18M6 6l12 12" />
</svg>
</button>
</div>
{/* Lyrics Container */} {/* Lyrics Container */}
<div <div

View file

@ -9,7 +9,7 @@ import AddToPlaylistModal from "@/components/AddToPlaylistModal";
import LyricsDetail from './LyricsDetail'; import LyricsDetail from './LyricsDetail';
export default function PlayerBar() { export default function PlayerBar() {
const { currentTrack, isPlaying, isBuffering, togglePlay, setBuffering, likedTracks, toggleLike, nextTrack, prevTrack, shuffle, toggleShuffle, repeatMode, toggleRepeat, audioQuality } = usePlayer(); const { currentTrack, isPlaying, isBuffering, togglePlay, setBuffering, likedTracks, toggleLike, nextTrack, prevTrack, shuffle, toggleShuffle, repeatMode, toggleRepeat, audioQuality, isLyricsOpen, toggleLyrics } = usePlayer();
const audioRef = useRef<HTMLAudioElement | null>(null); const audioRef = useRef<HTMLAudioElement | null>(null);
const [progress, setProgress] = useState(0); const [progress, setProgress] = useState(0);
const [duration, setDuration] = useState(0); const [duration, setDuration] = useState(0);
@ -17,7 +17,7 @@ export default function PlayerBar() {
// Modal State // Modal State
const [isAddToPlaylistOpen, setIsAddToPlaylistOpen] = useState(false); const [isAddToPlaylistOpen, setIsAddToPlaylistOpen] = useState(false);
const [isLyricsOpen, setIsLyricsOpen] = useState(false); // isLyricsOpen is now in context
const [isTechSpecsOpen, setIsTechSpecsOpen] = useState(false); const [isTechSpecsOpen, setIsTechSpecsOpen] = useState(false);
const [isFullScreenPlayerOpen, setIsFullScreenPlayerOpen] = useState(false); const [isFullScreenPlayerOpen, setIsFullScreenPlayerOpen] = useState(false);
const [isCoverModalOpen, setIsCoverModalOpen] = useState(false); const [isCoverModalOpen, setIsCoverModalOpen] = useState(false);
@ -145,6 +145,8 @@ export default function PlayerBar() {
return `${minutes}:${seconds.toString().padStart(2, '0')}`; return `${minutes}:${seconds.toString().padStart(2, '0')}`;
}; };
if (!currentTrack) return null;
return ( return (
<footer <footer
className="fixed bottom-[64px] left-2 right-2 md:left-0 md:right-0 md:bottom-0 h-14 md:h-[90px] bg-[#2E2E2E] md:bg-black border-t-0 md:border-t border-[#282828] flex items-center justify-between z-[60] rounded-lg md:rounded-none shadow-xl md:shadow-none transition-all duration-300" className="fixed bottom-[64px] left-2 right-2 md:left-0 md:right-0 md:bottom-0 h-14 md:h-[90px] bg-[#2E2E2E] md:bg-black border-t-0 md:border-t border-[#282828] flex items-center justify-between z-[60] rounded-lg md:rounded-none shadow-xl md:shadow-none transition-all duration-300"
@ -252,7 +254,7 @@ export default function PlayerBar() {
{/* Mobile Lyrics Button */} {/* Mobile Lyrics Button */}
<button <button
className={`transition ${isLyricsOpen ? 'text-green-500' : 'text-neutral-300'}`} className={`transition ${isLyricsOpen ? 'text-green-500' : 'text-neutral-300'}`}
onClick={(e) => { e.stopPropagation(); setIsLyricsOpen(!isLyricsOpen); }} onClick={(e) => { e.stopPropagation(); toggleLyrics(); }}
> >
<Mic2 size={22} /> <Mic2 size={22} />
</button> </button>
@ -316,7 +318,7 @@ export default function PlayerBar() {
<div className="flex items-center justify-end space-x-2 md:space-x-4"> <div className="flex items-center justify-end space-x-2 md:space-x-4">
<button <button
className={`text-zinc-400 hover:text-white transition ${isLyricsOpen ? 'text-green-500' : ''}`} className={`text-zinc-400 hover:text-white transition ${isLyricsOpen ? 'text-green-500' : ''}`}
onClick={() => setIsLyricsOpen(!isLyricsOpen)} onClick={() => toggleLyrics()}
title="Lyrics" title="Lyrics"
> >
<Mic2 size={20} /> <Mic2 size={20} />
@ -431,7 +433,7 @@ export default function PlayerBar() {
</button> </button>
<button <button
className={`transition ${isLyricsOpen ? 'text-green-500' : 'text-zinc-400'} hover:text-white`} className={`transition ${isLyricsOpen ? 'text-green-500' : 'text-zinc-400'} hover:text-white`}
onClick={(e) => { e.stopPropagation(); setIsLyricsOpen(!isLyricsOpen); }} onClick={(e) => { e.stopPropagation(); toggleLyrics(); }}
> >
<Mic2 size={24} /> <Mic2 size={24} />
</button> </button>
@ -467,19 +469,13 @@ export default function PlayerBar() {
/> />
)} )}
{/* Lyrics Sheet (Responsive) */} {/* Lyrics Sheet (Mobile Only - Desktop uses Right Sidebar) */}
{isLyricsOpen && currentTrack && ( {isLyricsOpen && currentTrack && (
<div className="fixed inset-0 md:inset-auto md:bottom-[100px] md:left-1/2 md:-translate-x-1/2 h-[100dvh] md:h-[500px] md:w-[600px] z-[70] bg-[#121212] md:bg-black/80 backdrop-blur-xl border-t md:border border-white/10 md:rounded-2xl shadow-2xl animate-in slide-in-from-bottom-full duration-500 overflow-hidden flex flex-col"> <div className="fixed inset-0 z-[70] bg-[#121212] flex flex-col md:hidden animate-in slide-in-from-bottom-full duration-300">
{/* Mobile Drag Handle Visual - Removed for full screen immersion */}
{/* <div
className="w-12 h-1 bg-white/20 rounded-full mx-auto mt-3 mb-1 md:hidden cursor-pointer"
onClick={() => setIsLyricsOpen(false)}
/> */}
<LyricsDetail <LyricsDetail
track={currentTrack} track={currentTrack}
currentTime={audioRef.current ? audioRef.current.currentTime : 0} currentTime={audioRef.current ? audioRef.current.currentTime : 0}
onClose={() => setIsLyricsOpen(false)} onClose={() => toggleLyrics()}
onSeek={(time) => { onSeek={(time) => {
if (audioRef.current) { if (audioRef.current) {
audioRef.current.currentTime = time; audioRef.current.currentTime = time;

View file

@ -0,0 +1,62 @@
"use client";
import { usePlayer } from "@/context/PlayerContext";
import LyricsDetail from "./LyricsDetail";
import { X } from "lucide-react";
import { useRef, useState, useEffect } from "react";
export default function RightSidebar() {
const { currentTrack, isLyricsOpen, toggleLyrics } = usePlayer();
const audioRef = useRef<HTMLAudioElement | null>(null);
const [currentTime, setCurrentTime] = useState(0);
// Sync current time from the global audio element
useEffect(() => {
const interval = setInterval(() => {
// Try to find the audio element in PlayerBar
const audio = document.querySelector('audio');
if (audio) {
setCurrentTime(audio.currentTime);
}
}, 100);
return () => clearInterval(interval);
}, []);
const handleSeek = (time: number) => {
const audio = document.querySelector('audio') as HTMLAudioElement;
if (audio) {
audio.currentTime = time;
}
};
// Only show on desktop when lyrics are open
if (!isLyricsOpen || !currentTrack) {
return null;
}
return (
<aside className="hidden lg:flex w-[350px] flex-shrink-0 bg-[#121212] rounded-lg flex-col overflow-hidden">
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-white/10">
<h2 className="text-lg font-bold">Lyrics</h2>
<button
onClick={toggleLyrics}
className="text-white/60 hover:text-white transition p-1"
>
<X size={20} />
</button>
</div>
{/* Lyrics Content */}
<div className="flex-1 overflow-hidden">
<LyricsDetail
track={currentTrack}
currentTime={currentTime}
onClose={toggleLyrics}
onSeek={handleSeek}
isInSidebar={true}
/>
</div>
</aside>
);
}

View file

@ -7,6 +7,8 @@ import { useState } from "react";
import CreatePlaylistModal from "./CreatePlaylistModal"; import CreatePlaylistModal from "./CreatePlaylistModal";
import { dbService } from "@/services/db"; import { dbService } from "@/services/db";
import { useLibrary } from "@/context/LibraryContext"; import { useLibrary } from "@/context/LibraryContext";
import Logo from "./Logo";
import CoverImage from "./CoverImage";
export default function Sidebar() { export default function Sidebar() {
const { likedTracks } = usePlayer(); const { likedTracks } = usePlayer();
@ -39,9 +41,9 @@ export default function Sidebar() {
return ( return (
<aside className="hidden md:flex flex-col w-[280px] bg-black h-full gap-2 p-2"> <aside className="hidden md:flex flex-col w-[280px] bg-black h-full gap-2 p-2">
<div className="bg-[#121212] rounded-lg p-4 flex flex-col gap-4"> <div className="bg-[#121212] rounded-lg p-4 flex flex-col gap-4">
{/* Logo replaces Home link */}
<Link href="/" className="flex items-center gap-4 text-spotify-text-muted hover:text-white transition cursor-pointer"> <Link href="/" className="flex items-center gap-4 text-spotify-text-muted hover:text-white transition cursor-pointer">
<Home className="w-6 h-6" /> <Logo />
<span className="font-bold">Home</span>
</Link> </Link>
<Link href="/search" className="flex items-center gap-4 text-spotify-text-muted hover:text-white transition cursor-pointer"> <Link href="/search" className="flex items-center gap-4 text-spotify-text-muted hover:text-white transition cursor-pointer">
<Search className="w-6 h-6" /> <Search className="w-6 h-6" />
@ -100,13 +102,11 @@ export default function Sidebar() {
{showPlaylists && userPlaylists.map((playlist) => ( {showPlaylists && userPlaylists.map((playlist) => (
<div key={playlist.id} className="group relative flex items-center gap-3 p-2 rounded-md hover:bg-[#1a1a1a] cursor-pointer"> <div key={playlist.id} className="group relative flex items-center gap-3 p-2 rounded-md hover:bg-[#1a1a1a] cursor-pointer">
<Link href={`/playlist?id=${playlist.id}`} className="flex-1 flex items-center gap-3"> <Link href={`/playlist?id=${playlist.id}`} className="flex-1 flex items-center gap-3">
<div className="w-12 h-12 bg-[#282828] rounded flex items-center justify-center overflow-hidden"> <CoverImage
{playlist.cover_url && !playlist.cover_url.includes("placehold") ? ( src={playlist.cover_url}
<img src={playlist.cover_url} alt="" className="w-full h-full object-cover" /> alt={playlist.title || ''}
) : ( className="w-12 h-12 rounded object-cover"
<span className="text-xl">🎵</span> />
)}
</div>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<h3 className="text-white font-medium truncate">{playlist.title}</h3> <h3 className="text-white font-medium truncate">{playlist.title}</h3>
<p className="text-sm text-spotify-text-muted truncate">Playlist You</p> <p className="text-sm text-spotify-text-muted truncate">Playlist You</p>
@ -128,13 +128,11 @@ export default function Sidebar() {
{showPlaylists && browsePlaylists.map((playlist) => ( {showPlaylists && browsePlaylists.map((playlist) => (
<div key={playlist.id} className="group relative flex items-center gap-3 p-2 rounded-md hover:bg-[#1a1a1a] cursor-pointer"> <div key={playlist.id} className="group relative flex items-center gap-3 p-2 rounded-md hover:bg-[#1a1a1a] cursor-pointer">
<Link href={`/playlist?id=${playlist.id}`} className="flex-1 flex items-center gap-3"> <Link href={`/playlist?id=${playlist.id}`} className="flex-1 flex items-center gap-3">
<div className="w-12 h-12 bg-[#282828] rounded flex items-center justify-center overflow-hidden"> <CoverImage
{playlist.cover_url && !playlist.cover_url.includes("placehold") ? ( src={playlist.cover_url}
<img src={playlist.cover_url} alt="" className="w-full h-full object-cover" /> alt={playlist.title || ''}
) : ( className="w-12 h-12 rounded object-cover"
<span className="text-xl">🎵</span> />
)}
</div>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<h3 className="text-white font-medium truncate">{playlist.title}</h3> <h3 className="text-white font-medium truncate">{playlist.title}</h3>
<p className="text-sm text-spotify-text-muted truncate">Playlist Made for you</p> <p className="text-sm text-spotify-text-muted truncate">Playlist Made for you</p>
@ -145,13 +143,14 @@ export default function Sidebar() {
{/* Artists */} {/* Artists */}
{showArtists && artists.map((artist) => ( {showArtists && artists.map((artist) => (
<Link href={`/search?q=${encodeURIComponent(artist.title)}`} key={artist.id}> <Link href={`/artist?name=${encodeURIComponent(artist.title)}`} key={artist.id}>
<div className="flex items-center gap-3 p-2 rounded-md hover:bg-[#1a1a1a] cursor-pointer"> <div className="flex items-center gap-3 p-2 rounded-md hover:bg-[#1a1a1a] cursor-pointer">
<div className="w-12 h-12 bg-[#282828] rounded-full flex items-center justify-center overflow-hidden relative"> <CoverImage
{artist.cover_url ? ( src={artist.cover_url}
<img src={artist.cover_url} alt="" className="w-full h-full object-cover" onError={(e) => e.currentTarget.style.display = 'none'} /> alt={artist.title}
) : null} className="w-12 h-12 rounded-full object-cover"
</div> fallbackText={artist.title?.substring(0, 2).toUpperCase()}
/>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<h3 className="text-white font-medium truncate">{artist.title}</h3> <h3 className="text-white font-medium truncate">{artist.title}</h3>
<p className="text-sm text-spotify-text-muted truncate">Artist</p> <p className="text-sm text-spotify-text-muted truncate">Artist</p>

View file

@ -0,0 +1,13 @@
import React from 'react';
interface SkeletonProps {
className?: string;
}
export default function Skeleton({ className = "" }: SkeletonProps) {
return (
<div
className={`animate-pulse bg-gray-700/50 rounded-md ${className}`}
/>
);
}

View file

@ -2,27 +2,9 @@
import { createContext, useContext, useState, useEffect, ReactNode } from "react"; import { createContext, useContext, useState, useEffect, ReactNode } from "react";
import { dbService } from "@/services/db"; import { dbService } from "@/services/db";
import { Track, AudioQuality } from "@/types";
interface Track {
title: string;
artist: string;
album: string;
cover_url: string;
id: string;
url?: string;
}
import * as mm from 'music-metadata-browser'; import * as mm from 'music-metadata-browser';
interface AudioQuality {
format: string;
sampleRate: number;
bitDepth?: number;
bitrate: number;
channels: number;
codec?: string;
}
interface PlayerContextType { interface PlayerContextType {
currentTrack: Track | null; currentTrack: Track | null;
isPlaying: boolean; isPlaying: boolean;
@ -41,6 +23,9 @@ interface PlayerContextType {
toggleLike: (track: Track) => void; toggleLike: (track: Track) => void;
playHistory: Track[]; playHistory: Track[];
audioQuality: AudioQuality | null; audioQuality: AudioQuality | null;
// Lyrics panel state
isLyricsOpen: boolean;
toggleLyrics: () => void;
} }
const PlayerContext = createContext<PlayerContextType | undefined>(undefined); const PlayerContext = createContext<PlayerContextType | undefined>(undefined);
@ -65,6 +50,10 @@ export function PlayerProvider({ children }: { children: ReactNode }) {
// History State // History State
const [playHistory, setPlayHistory] = useState<Track[]>([]); const [playHistory, setPlayHistory] = useState<Track[]>([]);
// Lyrics Panel State
const [isLyricsOpen, setIsLyricsOpen] = useState(false);
const toggleLyrics = () => setIsLyricsOpen(prev => !prev);
// Load Likes from DB // Load Likes from DB
useEffect(() => { useEffect(() => {
dbService.getLikedSongs().then(tracks => { dbService.getLikedSongs().then(tracks => {
@ -272,7 +261,9 @@ export function PlayerProvider({ children }: { children: ReactNode }) {
setBuffering, setBuffering,
toggleLike, toggleLike,
playHistory, playHistory,
audioQuality audioQuality,
isLyricsOpen,
toggleLyrics
}}> }}>
{children} {children}
</PlayerContext.Provider> </PlayerContext.Provider>

View file

@ -13,12 +13,15 @@ const nextConfig = {
// Backend API Proxies (Specific, so we don't block NextAuth at /api/auth) // Backend API Proxies (Specific, so we don't block NextAuth at /api/auth)
{ source: '/api/browse/:path*', destination: 'http://127.0.0.1:8000/api/browse/:path*' }, { source: '/api/browse/:path*', destination: 'http://127.0.0.1:8000/api/browse/:path*' },
{ source: '/api/playlists/:path*', destination: 'http://127.0.0.1:8000/api/playlists/:path*' }, { source: '/api/playlists/:path*', destination: 'http://127.0.0.1:8000/api/playlists/:path*' },
{ source: '/api/search', destination: 'http://127.0.0.1:8000/api/search' },
{ source: '/api/search/:path*', destination: 'http://127.0.0.1:8000/api/search/:path*' }, { source: '/api/search/:path*', destination: 'http://127.0.0.1:8000/api/search/:path*' },
{ source: '/api/artist/:path*', destination: 'http://127.0.0.1:8000/api/artist/:path*' },
{ source: '/api/stream/:path*', destination: 'http://127.0.0.1:8000/api/stream/:path*' }, { source: '/api/stream/:path*', destination: 'http://127.0.0.1:8000/api/stream/:path*' },
{ source: '/api/download/:path*', destination: 'http://127.0.0.1:8000/api/download/:path*' }, { source: '/api/download/:path*', destination: 'http://127.0.0.1:8000/api/download/:path*' },
{ source: '/api/download-status/:path*', destination: 'http://127.0.0.1:8000/api/download-status/:path*' }, { source: '/api/download-status/:path*', destination: 'http://127.0.0.1:8000/api/download-status/:path*' },
{ source: '/api/lyrics/:path*', destination: 'http://127.0.0.1:8000/api/lyrics/:path*' }, { source: '/api/lyrics/:path*', destination: 'http://127.0.0.1:8000/api/lyrics/:path*' },
{ source: '/api/trending/:path*', destination: 'http://127.0.0.1:8000/api/trending/:path*' }, { source: '/api/trending/:path*', destination: 'http://127.0.0.1:8000/api/trending/:path*' },
{ source: '/api/recommendations/:path*', destination: 'http://127.0.0.1:8000/api/recommendations/:path*' },
]; ];
}, },
images: { images: {

View file

@ -22,7 +22,7 @@
"@types/node": "^20", "@types/node": "^20",
"@types/react": "^19", "@types/react": "^19",
"@types/react-dom": "^19", "@types/react-dom": "^19",
"eslint": "^9", "eslint": "^8",
"eslint-config-next": "^14.2.0", "eslint-config-next": "^14.2.0",
"tailwindcss": "^3.4.1", "tailwindcss": "^3.4.1",
"typescript": "^5" "typescript": "^5"
@ -94,19 +94,6 @@
"eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0"
} }
}, },
"node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": {
"version": "3.4.3",
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz",
"integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
},
"funding": {
"url": "https://opencollective.com/eslint"
}
},
"node_modules/@eslint-community/regexpp": { "node_modules/@eslint-community/regexpp": {
"version": "4.12.2", "version": "4.12.2",
"resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz",
@ -117,130 +104,54 @@
"node": "^12.0.0 || ^14.0.0 || >=16.0.0" "node": "^12.0.0 || ^14.0.0 || >=16.0.0"
} }
}, },
"node_modules/@eslint/config-array": {
"version": "0.21.1",
"resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.1.tgz",
"integrity": "sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@eslint/object-schema": "^2.1.7",
"debug": "^4.3.1",
"minimatch": "^3.1.2"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
}
},
"node_modules/@eslint/config-helpers": {
"version": "0.4.2",
"resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz",
"integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@eslint/core": "^0.17.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
}
},
"node_modules/@eslint/core": {
"version": "0.17.0",
"resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz",
"integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@types/json-schema": "^7.0.15"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
}
},
"node_modules/@eslint/eslintrc": { "node_modules/@eslint/eslintrc": {
"version": "3.3.3", "version": "2.1.4",
"resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.3.tgz", "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.4.tgz",
"integrity": "sha512-Kr+LPIUVKz2qkx1HAMH8q1q6azbqBAsXJUxBl/ODDuVPX45Z9DfwB8tPjTi6nNZ8BuM3nbJxC5zCAg5elnBUTQ==", "integrity": "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"ajv": "^6.12.4", "ajv": "^6.12.4",
"debug": "^4.3.2", "debug": "^4.3.2",
"espree": "^10.0.1", "espree": "^9.6.0",
"globals": "^14.0.0", "globals": "^13.19.0",
"ignore": "^5.2.0", "ignore": "^5.2.0",
"import-fresh": "^3.2.1", "import-fresh": "^3.2.1",
"js-yaml": "^4.1.1", "js-yaml": "^4.1.0",
"minimatch": "^3.1.2", "minimatch": "^3.1.2",
"strip-json-comments": "^3.1.1" "strip-json-comments": "^3.1.1"
}, },
"engines": { "engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0" "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
}, },
"funding": { "funding": {
"url": "https://opencollective.com/eslint" "url": "https://opencollective.com/eslint"
} }
}, },
"node_modules/@eslint/js": { "node_modules/@eslint/js": {
"version": "9.39.2", "version": "8.57.1",
"resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.2.tgz", "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.1.tgz",
"integrity": "sha512-q1mjIoW1VX4IvSocvM/vbTiveKC4k9eLrajNEuSsmjymSDEbpGddtpfOoN7YGAqBK3NG+uqo8ia4PDTt8buCYA==", "integrity": "sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0" "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
},
"funding": {
"url": "https://eslint.org/donate"
} }
}, },
"node_modules/@eslint/object-schema": { "node_modules/@humanwhocodes/config-array": {
"version": "2.1.7", "version": "0.13.0",
"resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz",
"integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", "integrity": "sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==",
"dev": true, "deprecated": "Use @eslint/config-array instead",
"license": "Apache-2.0",
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
}
},
"node_modules/@eslint/plugin-kit": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz",
"integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@eslint/core": "^0.17.0", "@humanwhocodes/object-schema": "^2.0.3",
"levn": "^0.4.1" "debug": "^4.3.1",
"minimatch": "^3.0.5"
}, },
"engines": { "engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0" "node": ">=10.10.0"
}
},
"node_modules/@humanfs/core": {
"version": "0.19.1",
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
"integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=18.18.0"
}
},
"node_modules/@humanfs/node": {
"version": "0.16.7",
"resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz",
"integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@humanfs/core": "^0.19.1",
"@humanwhocodes/retry": "^0.4.0"
},
"engines": {
"node": ">=18.18.0"
} }
}, },
"node_modules/@humanwhocodes/module-importer": { "node_modules/@humanwhocodes/module-importer": {
@ -257,19 +168,13 @@
"url": "https://github.com/sponsors/nzakas" "url": "https://github.com/sponsors/nzakas"
} }
}, },
"node_modules/@humanwhocodes/retry": { "node_modules/@humanwhocodes/object-schema": {
"version": "0.4.3", "version": "2.0.3",
"resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.3.tgz",
"integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", "integrity": "sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==",
"deprecated": "Use @eslint/object-schema instead",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "BSD-3-Clause"
"engines": {
"node": ">=18.18"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/nzakas"
}
}, },
"node_modules/@isaacs/cliui": { "node_modules/@isaacs/cliui": {
"version": "8.0.2", "version": "8.0.2",
@ -903,20 +808,6 @@
"tslib": "^2.4.0" "tslib": "^2.4.0"
} }
}, },
"node_modules/@types/estree": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
"integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/json-schema": {
"version": "7.0.15",
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
"integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/json5": { "node_modules/@types/json5": {
"version": "0.0.29", "version": "0.0.29",
"resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz", "resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz",
@ -1088,18 +979,12 @@
"url": "https://opencollective.com/typescript-eslint" "url": "https://opencollective.com/typescript-eslint"
} }
}, },
"node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { "node_modules/@ungap/structured-clone": {
"version": "3.4.3", "version": "1.3.0",
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz",
"integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "ISC"
"engines": {
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
},
"funding": {
"url": "https://opencollective.com/eslint"
}
}, },
"node_modules/@unrs/resolver-binding-android-arm-eabi": { "node_modules/@unrs/resolver-binding-android-arm-eabi": {
"version": "1.11.1", "version": "1.11.1",
@ -1472,6 +1357,19 @@
"node": ">= 8" "node": ">= 8"
} }
}, },
"node_modules/anymatch/node_modules/picomatch": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8.6"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/arg": { "node_modules/arg": {
"version": "5.0.2", "version": "5.0.2",
"resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz",
@ -2548,63 +2446,60 @@
} }
}, },
"node_modules/eslint": { "node_modules/eslint": {
"version": "9.39.2", "version": "8.57.1",
"resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.2.tgz", "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.1.tgz",
"integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "integrity": "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==",
"deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/eslint-utils": "^4.2.0",
"@eslint-community/regexpp": "^4.12.1", "@eslint-community/regexpp": "^4.6.1",
"@eslint/config-array": "^0.21.1", "@eslint/eslintrc": "^2.1.4",
"@eslint/config-helpers": "^0.4.2", "@eslint/js": "8.57.1",
"@eslint/core": "^0.17.0", "@humanwhocodes/config-array": "^0.13.0",
"@eslint/eslintrc": "^3.3.1",
"@eslint/js": "9.39.2",
"@eslint/plugin-kit": "^0.4.1",
"@humanfs/node": "^0.16.6",
"@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/module-importer": "^1.0.1",
"@humanwhocodes/retry": "^0.4.2", "@nodelib/fs.walk": "^1.2.8",
"@types/estree": "^1.0.6", "@ungap/structured-clone": "^1.2.0",
"ajv": "^6.12.4", "ajv": "^6.12.4",
"chalk": "^4.0.0", "chalk": "^4.0.0",
"cross-spawn": "^7.0.6", "cross-spawn": "^7.0.2",
"debug": "^4.3.2", "debug": "^4.3.2",
"doctrine": "^3.0.0",
"escape-string-regexp": "^4.0.0", "escape-string-regexp": "^4.0.0",
"eslint-scope": "^8.4.0", "eslint-scope": "^7.2.2",
"eslint-visitor-keys": "^4.2.1", "eslint-visitor-keys": "^3.4.3",
"espree": "^10.4.0", "espree": "^9.6.1",
"esquery": "^1.5.0", "esquery": "^1.4.2",
"esutils": "^2.0.2", "esutils": "^2.0.2",
"fast-deep-equal": "^3.1.3", "fast-deep-equal": "^3.1.3",
"file-entry-cache": "^8.0.0", "file-entry-cache": "^6.0.1",
"find-up": "^5.0.0", "find-up": "^5.0.0",
"glob-parent": "^6.0.2", "glob-parent": "^6.0.2",
"globals": "^13.19.0",
"graphemer": "^1.4.0",
"ignore": "^5.2.0", "ignore": "^5.2.0",
"imurmurhash": "^0.1.4", "imurmurhash": "^0.1.4",
"is-glob": "^4.0.0", "is-glob": "^4.0.0",
"is-path-inside": "^3.0.3",
"js-yaml": "^4.1.0",
"json-stable-stringify-without-jsonify": "^1.0.1", "json-stable-stringify-without-jsonify": "^1.0.1",
"levn": "^0.4.1",
"lodash.merge": "^4.6.2", "lodash.merge": "^4.6.2",
"minimatch": "^3.1.2", "minimatch": "^3.1.2",
"natural-compare": "^1.4.0", "natural-compare": "^1.4.0",
"optionator": "^0.9.3" "optionator": "^0.9.3",
"strip-ansi": "^6.0.1",
"text-table": "^0.2.0"
}, },
"bin": { "bin": {
"eslint": "bin/eslint.js" "eslint": "bin/eslint.js"
}, },
"engines": { "engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0" "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
}, },
"funding": { "funding": {
"url": "https://eslint.org/donate" "url": "https://opencollective.com/eslint"
},
"peerDependencies": {
"jiti": "*"
},
"peerDependenciesMeta": {
"jiti": {
"optional": true
}
} }
}, },
"node_modules/eslint-config-next": { "node_modules/eslint-config-next": {
@ -2878,9 +2773,9 @@
} }
}, },
"node_modules/eslint-scope": { "node_modules/eslint-scope": {
"version": "8.4.0", "version": "7.2.2",
"resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz",
"integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", "integrity": "sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==",
"dev": true, "dev": true,
"license": "BSD-2-Clause", "license": "BSD-2-Clause",
"dependencies": { "dependencies": {
@ -2888,38 +2783,74 @@
"estraverse": "^5.2.0" "estraverse": "^5.2.0"
}, },
"engines": { "engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0" "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
}, },
"funding": { "funding": {
"url": "https://opencollective.com/eslint" "url": "https://opencollective.com/eslint"
} }
}, },
"node_modules/eslint-visitor-keys": { "node_modules/eslint-visitor-keys": {
"version": "4.2.1", "version": "3.4.3",
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz",
"integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"engines": { "engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0" "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
}, },
"funding": { "funding": {
"url": "https://opencollective.com/eslint" "url": "https://opencollective.com/eslint"
} }
}, },
"node_modules/eslint/node_modules/ansi-regex": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/eslint/node_modules/doctrine": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz",
"integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"esutils": "^2.0.2"
},
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/eslint/node_modules/strip-ansi": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-regex": "^5.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/espree": { "node_modules/espree": {
"version": "10.4.0", "version": "9.6.1",
"resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", "resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz",
"integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", "integrity": "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==",
"dev": true, "dev": true,
"license": "BSD-2-Clause", "license": "BSD-2-Clause",
"dependencies": { "dependencies": {
"acorn": "^8.15.0", "acorn": "^8.9.0",
"acorn-jsx": "^5.3.2", "acorn-jsx": "^5.3.2",
"eslint-visitor-keys": "^4.2.1" "eslint-visitor-keys": "^3.4.1"
}, },
"engines": { "engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0" "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
}, },
"funding": { "funding": {
"url": "https://opencollective.com/eslint" "url": "https://opencollective.com/eslint"
@ -3069,16 +3000,16 @@
} }
}, },
"node_modules/file-entry-cache": { "node_modules/file-entry-cache": {
"version": "8.0.0", "version": "6.0.1",
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz",
"integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", "integrity": "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"flat-cache": "^4.0.0" "flat-cache": "^3.0.4"
}, },
"engines": { "engines": {
"node": ">=16.0.0" "node": "^10.12.0 || >=12.0.0"
} }
}, },
"node_modules/file-type": { "node_modules/file-type": {
@ -3129,17 +3060,18 @@
} }
}, },
"node_modules/flat-cache": { "node_modules/flat-cache": {
"version": "4.0.1", "version": "3.2.0",
"resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.2.0.tgz",
"integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", "integrity": "sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"flatted": "^3.2.9", "flatted": "^3.2.9",
"keyv": "^4.5.4" "keyv": "^4.5.3",
"rimraf": "^3.0.2"
}, },
"engines": { "engines": {
"node": ">=16" "node": "^10.12.0 || >=12.0.0"
} }
}, },
"node_modules/flatted": { "node_modules/flatted": {
@ -3195,6 +3127,13 @@
"url": "https://github.com/sponsors/rawify" "url": "https://github.com/sponsors/rawify"
} }
}, },
"node_modules/fs.realpath": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
"integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==",
"dev": true,
"license": "ISC"
},
"node_modules/fsevents": { "node_modules/fsevents": {
"version": "2.3.3", "version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
@ -3394,13 +3333,16 @@
} }
}, },
"node_modules/globals": { "node_modules/globals": {
"version": "14.0.0", "version": "13.24.0",
"resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz",
"integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": {
"type-fest": "^0.20.2"
},
"engines": { "engines": {
"node": ">=18" "node": ">=8"
}, },
"funding": { "funding": {
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
@ -3463,6 +3405,13 @@
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
"license": "ISC" "license": "ISC"
}, },
"node_modules/graphemer": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz",
"integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==",
"dev": true,
"license": "MIT"
},
"node_modules/has-bigints": { "node_modules/has-bigints": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz",
@ -3620,6 +3569,25 @@
"node": ">=0.8.19" "node": ">=0.8.19"
} }
}, },
"node_modules/inflight": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
"integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==",
"deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.",
"dev": true,
"license": "ISC",
"dependencies": {
"once": "^1.3.0",
"wrappy": "1"
}
},
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"dev": true,
"license": "ISC"
},
"node_modules/internal-slot": { "node_modules/internal-slot": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz",
@ -3915,6 +3883,16 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/is-path-inside": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz",
"integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/is-regex": { "node_modules/is-regex": {
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz",
@ -4616,6 +4594,19 @@
"node": ">=8.6" "node": ">=8.6"
} }
}, },
"node_modules/micromatch/node_modules/picomatch": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8.6"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/minimatch": { "node_modules/minimatch": {
"version": "3.1.2", "version": "3.1.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
@ -4976,6 +4967,16 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/once": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
"integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
"dev": true,
"license": "ISC",
"dependencies": {
"wrappy": "1"
}
},
"node_modules/optionator": { "node_modules/optionator": {
"version": "0.9.4", "version": "0.9.4",
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
@ -5067,6 +5068,16 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/path-is-absolute": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
"integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/path-key": { "node_modules/path-key": {
"version": "3.1.1", "version": "3.1.1",
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
@ -5131,13 +5142,13 @@
"license": "ISC" "license": "ISC"
}, },
"node_modules/picomatch": { "node_modules/picomatch": {
"version": "2.3.1", "version": "4.0.3",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=8.6" "node": ">=12"
}, },
"funding": { "funding": {
"url": "https://github.com/sponsors/jonschlinkert" "url": "https://github.com/sponsors/jonschlinkert"
@ -5489,6 +5500,19 @@
"node": ">=8.10.0" "node": ">=8.10.0"
} }
}, },
"node_modules/readdirp/node_modules/picomatch": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8.6"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/reflect.getprototypeof": { "node_modules/reflect.getprototypeof": {
"version": "1.0.10", "version": "1.0.10",
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
@ -5585,6 +5609,45 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/rimraf": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz",
"integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==",
"deprecated": "Rimraf versions prior to v4 are no longer supported",
"dev": true,
"license": "ISC",
"dependencies": {
"glob": "^7.1.3"
},
"bin": {
"rimraf": "bin.js"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/rimraf/node_modules/glob": {
"version": "7.2.3",
"resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
"integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==",
"deprecated": "Glob versions prior to v9 are no longer supported",
"dev": true,
"license": "ISC",
"dependencies": {
"fs.realpath": "^1.0.0",
"inflight": "^1.0.4",
"inherits": "2",
"minimatch": "^3.1.1",
"once": "^1.3.0",
"path-is-absolute": "^1.0.0"
},
"engines": {
"node": "*"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/run-parallel": { "node_modules/run-parallel": {
"version": "1.2.0", "version": "1.2.0",
"resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
@ -6315,6 +6378,13 @@
"url": "https://opencollective.com/webpack" "url": "https://opencollective.com/webpack"
} }
}, },
"node_modules/text-table": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz",
"integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==",
"dev": true,
"license": "MIT"
},
"node_modules/thenify": { "node_modules/thenify": {
"version": "3.3.1", "version": "3.3.1",
"resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz",
@ -6355,19 +6425,6 @@
"url": "https://github.com/sponsors/SuperchupuDev" "url": "https://github.com/sponsors/SuperchupuDev"
} }
}, },
"node_modules/tinyglobby/node_modules/picomatch": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/to-regex-range": { "node_modules/to-regex-range": {
"version": "5.0.1", "version": "5.0.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
@ -6450,6 +6507,19 @@
"node": ">= 0.8.0" "node": ">= 0.8.0"
} }
}, },
"node_modules/type-fest": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz",
"integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==",
"dev": true,
"license": "(MIT OR CC0-1.0)",
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/typed-array-buffer": { "node_modules/typed-array-buffer": {
"version": "1.0.3", "version": "1.0.3",
"resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz",
@ -6860,6 +6930,13 @@
"url": "https://github.com/chalk/ansi-styles?sponsor=1" "url": "https://github.com/chalk/ansi-styles?sponsor=1"
} }
}, },
"node_modules/wrappy": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"dev": true,
"license": "ISC"
},
"node_modules/yaml": { "node_modules/yaml": {
"version": "2.8.2", "version": "2.8.2",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz", "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz",

View file

@ -23,9 +23,9 @@
"@types/node": "^20", "@types/node": "^20",
"@types/react": "^19", "@types/react": "^19",
"@types/react-dom": "^19", "@types/react-dom": "^19",
"eslint": "^9", "eslint": "^8",
"eslint-config-next": "^14.2.0", "eslint-config-next": "^14.2.0",
"tailwindcss": "^3.4.1", "tailwindcss": "^3.4.1",
"typescript": "^5" "typescript": "^5"
} }
} }

Binary file not shown.

Before

Width:  |  Height:  |  Size: 277 KiB

After

Width:  |  Height:  |  Size: 386 KiB

View file

@ -1,22 +1,7 @@
import { openDB, DBSchema } from 'idb'; import { openDB, DBSchema } from 'idb';
import { Track, Playlist } from '@/types';
export interface Track { export type { Track, Playlist };
id: string;
title: string;
artist: string;
album: string;
cover_url: string;
url?: string;
duration?: number;
}
export interface Playlist {
id: string;
title: string;
tracks: Track[];
createdAt: number;
cover_url?: string;
}
interface MyDB extends DBSchema { interface MyDB extends DBSchema {
playlists: { playlists: {

View file

@ -1,61 +0,0 @@
import { Track } from "./db";
export interface StaticPlaylist {
id: string;
title: string;
description: string;
cover_url: string;
tracks: Track[];
}
export const libraryService = {
async getLibrary(): Promise<StaticPlaylist> {
const res = await fetch('/library.json');
if (!res.ok) throw new Error("Failed to load library");
const data = await res.json();
// MOCK: Replace URLs with a working sample for testing Audiophile features
// In a real local-first app, these would be relative paths to local files or S3 presigned URLs.
data.tracks = data.tracks.map((t: any) => ({
...t,
url: "https://www.soundhelix.com/examples/mp3/SoundHelix-Song-1.mp3"
}));
return data;
},
async getBrowseContent(): Promise<Record<string, StaticPlaylist[]>> {
const data = await this.getLibrary();
// Mock categories using the single playlist we have
return {
"Top Lists": [data],
"Just For You": [data],
"Trending": [data]
};
},
async getPlaylist(id: string): Promise<StaticPlaylist | null> {
const data = await this.getLibrary();
if (data.id === id) return data;
return null;
},
async getRecommendations(seedTrackId?: string): Promise<Track[]> {
const data = await this.getLibrary();
// Return random 10 tracks
return [...data.tracks].sort(() => 0.5 - Math.random()).slice(0, 10);
},
async getRecommendedAlbums(seedArtist?: string): Promise<StaticPlaylist[]> {
const data = await this.getLibrary();
// Return the main playlist as a recommended album for now
return [data];
},
async search(query: string): Promise<Track[]> {
const data = await this.getLibrary();
const lowerQuery = query.toLowerCase();
return data.tracks.filter(t =>
t.title.toLowerCase().includes(lowerQuery) ||
t.artist.toLowerCase().includes(lowerQuery)
);
}
};

43
frontend/types/index.ts Normal file
View file

@ -0,0 +1,43 @@
// Shared type definitions for the Spotify Clone application
export interface Track {
id: string;
title: string;
artist: string;
album: string;
cover_url: string;
url?: string;
duration?: number;
}
export interface Playlist {
id: string;
title: string;
description?: string;
tracks: Track[];
createdAt: number;
cover_url?: string;
}
export interface AudioQuality {
format: string;
sampleRate: number;
bitDepth?: number;
bitrate: number;
channels: number;
codec?: string;
}
export interface LyricLine {
time: number;
text: string;
}
export interface SearchResult {
id: string;
title: string;
artist: string;
album: string;
cover_url: string;
duration?: number;
}

1
playlist_final.json Normal file
View file

@ -0,0 +1 @@
{"id":null,"title":"Thch Th n (Remix)","description":"","author":"Lê bảo bình","cover_url":"https://lh3.googleusercontent.com/wlJb64jqoA3KHokhIxN0FzWdJXvBgTYx6bdvrqGSqP_Ux7uLmQTA0MLfsM5AsYFL2Hl6J83SMfw9njj5=w544-h544-l90-rj","tracks":[{"title":"Thích Thì Đến (Lofi)","artist":"Lê bảo bình","album":"Thích Thì Đến (Remix)","duration":197,"cover_url":"https://lh3.googleusercontent.com/wlJb64jqoA3KHokhIxN0FzWdJXvBgTYx6bdvrqGSqP_Ux7uLmQTA0MLfsM5AsYFL2Hl6J83SMfw9njj5=w544-h544-l90-rj","id":"NLBiuA2TuXs","url":"https://music.youtube.com/watch?v=NLBiuA2TuXs"},{"title":"Thích Thì Đến (Beat Lofi)","artist":"Lê bảo bình","album":"Thích Thì Đến (Remix)","duration":197,"cover_url":"https://lh3.googleusercontent.com/wlJb64jqoA3KHokhIxN0FzWdJXvBgTYx6bdvrqGSqP_Ux7uLmQTA0MLfsM5AsYFL2Hl6J83SMfw9njj5=w544-h544-l90-rj","id":"MMWZclWtfOw","url":"https://music.youtube.com/watch?v=MMWZclWtfOw"},{"title":"Thích Thì Đến (Remix)","artist":"Lê bảo bình","album":"Thích Thì Đến (Remix)","duration":248,"cover_url":"https://lh3.googleusercontent.com/wlJb64jqoA3KHokhIxN0FzWdJXvBgTYx6bdvrqGSqP_Ux7uLmQTA0MLfsM5AsYFL2Hl6J83SMfw9njj5=w544-h544-l90-rj","id":"PJ3xRwSAG88","url":"https://music.youtube.com/watch?v=PJ3xRwSAG88"},{"title":"Thích Thì Đến (Beat Remix)","artist":"Lê bảo bình","album":"Thích Thì Đến (Remix)","duration":248,"cover_url":"https://lh3.googleusercontent.com/wlJb64jqoA3KHokhIxN0FzWdJXvBgTYx6bdvrqGSqP_Ux7uLmQTA0MLfsM5AsYFL2Hl6J83SMfw9njj5=w544-h544-l90-rj","id":"nPUucoJkMq8","url":"https://music.youtube.com/watch?v=nPUucoJkMq8"},{"title":"Thích Thì Đến (Deephouse)","artist":"Lê bảo bình","album":"Thích Thì Đến (Remix)","duration":216,"cover_url":"https://lh3.googleusercontent.com/wlJb64jqoA3KHokhIxN0FzWdJXvBgTYx6bdvrqGSqP_Ux7uLmQTA0MLfsM5AsYFL2Hl6J83SMfw9njj5=w544-h544-l90-rj","id":"xRG4IivcvTg","url":"https://music.youtube.com/watch?v=xRG4IivcvTg"},{"title":"Thích Thì Đến (Beat Deephouse)","artist":"Lê bảo bình","album":"Thích Thì Đến (Remix)","duration":216,"cover_url":"https://lh3.googleusercontent.com/wlJb64jqoA3KHokhIxN0FzWdJXvBgTYx6bdvrqGSqP_Ux7uLmQTA0MLfsM5AsYFL2Hl6J83SMfw9njj5=w544-h544-l90-rj","id":"pifCyHStEgs","url":"https://music.youtube.com/watch?v=pifCyHStEgs"}]}

1
playlist_response.json Normal file
View file

@ -0,0 +1 @@
{"detail":"Playlist not found"}

View file

@ -0,0 +1 @@
{"detail":"Playlist not found"}

View file

@ -0,0 +1 @@
{"detail":"Playlist not found"}

View file

@ -0,0 +1 @@
{"id":null,"title":"Thch Th n (Remix)","description":"","author":"Lê bảo bình","cover_url":"https://lh3.googleusercontent.com/wlJb64jqoA3KHokhIxN0FzWdJXvBgTYx6bdvrqGSqP_Ux7uLmQTA0MLfsM5AsYFL2Hl6J83SMfw9njj5=w544-h544-l90-rj","tracks":[{"title":"Thích Thì Đến (Lofi)","artist":"Lê bảo bình","album":"Thích Thì Đến (Remix)","duration":197,"cover_url":"https://lh3.googleusercontent.com/wlJb64jqoA3KHokhIxN0FzWdJXvBgTYx6bdvrqGSqP_Ux7uLmQTA0MLfsM5AsYFL2Hl6J83SMfw9njj5=w544-h544-l90-rj","id":"NLBiuA2TuXs","url":"https://music.youtube.com/watch?v=NLBiuA2TuXs"},{"title":"Thích Thì Đến (Beat Lofi)","artist":"Lê bảo bình","album":"Thích Thì Đến (Remix)","duration":197,"cover_url":"https://lh3.googleusercontent.com/wlJb64jqoA3KHokhIxN0FzWdJXvBgTYx6bdvrqGSqP_Ux7uLmQTA0MLfsM5AsYFL2Hl6J83SMfw9njj5=w544-h544-l90-rj","id":"MMWZclWtfOw","url":"https://music.youtube.com/watch?v=MMWZclWtfOw"},{"title":"Thích Thì Đến (Remix)","artist":"Lê bảo bình","album":"Thích Thì Đến (Remix)","duration":248,"cover_url":"https://lh3.googleusercontent.com/wlJb64jqoA3KHokhIxN0FzWdJXvBgTYx6bdvrqGSqP_Ux7uLmQTA0MLfsM5AsYFL2Hl6J83SMfw9njj5=w544-h544-l90-rj","id":"PJ3xRwSAG88","url":"https://music.youtube.com/watch?v=PJ3xRwSAG88"},{"title":"Thích Thì Đến (Beat Remix)","artist":"Lê bảo bình","album":"Thích Thì Đến (Remix)","duration":248,"cover_url":"https://lh3.googleusercontent.com/wlJb64jqoA3KHokhIxN0FzWdJXvBgTYx6bdvrqGSqP_Ux7uLmQTA0MLfsM5AsYFL2Hl6J83SMfw9njj5=w544-h544-l90-rj","id":"nPUucoJkMq8","url":"https://music.youtube.com/watch?v=nPUucoJkMq8"},{"title":"Thích Thì Đến (Deephouse)","artist":"Lê bảo bình","album":"Thích Thì Đến (Remix)","duration":216,"cover_url":"https://lh3.googleusercontent.com/wlJb64jqoA3KHokhIxN0FzWdJXvBgTYx6bdvrqGSqP_Ux7uLmQTA0MLfsM5AsYFL2Hl6J83SMfw9njj5=w544-h544-l90-rj","id":"xRG4IivcvTg","url":"https://music.youtube.com/watch?v=xRG4IivcvTg"},{"title":"Thích Thì Đến (Beat Deephouse)","artist":"Lê bảo bình","album":"Thích Thì Đến (Remix)","duration":216,"cover_url":"https://lh3.googleusercontent.com/wlJb64jqoA3KHokhIxN0FzWdJXvBgTYx6bdvrqGSqP_Ux7uLmQTA0MLfsM5AsYFL2Hl6J83SMfw9njj5=w544-h544-l90-rj","id":"pifCyHStEgs","url":"https://music.youtube.com/watch?v=pifCyHStEgs"}]}

View file

@ -0,0 +1 @@
{"detail":"Playlist not found"}

View file

@ -0,0 +1 @@
{"detail":"Playlist not found"}

View file

@ -0,0 +1 @@
{"detail":"Playlist not found"}

View file

@ -0,0 +1 @@
{"detail":"Playlist not found"}

View file

@ -0,0 +1 @@
{"detail":"Playlist not found"}

View file

@ -0,0 +1 @@
{"detail":"Playlist not found"}

View file

@ -0,0 +1 @@
{"detail":"Playlist not found"}

View file

@ -0,0 +1 @@
{"detail":"Playlist not found"}

1
rec_response_v2.json Normal file

File diff suppressed because one or more lines are too long

1
response.json Normal file

File diff suppressed because one or more lines are too long

1
response_v2.json Normal file

File diff suppressed because one or more lines are too long

1
response_v3.json Normal file

File diff suppressed because one or more lines are too long