Update: Mobile UX, Show All, Infinite Scroll & New Categories
This commit is contained in:
parent
c8c232afe9
commit
35876ee046
49 changed files with 80580 additions and 7584 deletions
7
.gitignore
vendored
7
.gitignore
vendored
|
|
@ -53,3 +53,10 @@ backend/data/*.json
|
|||
!backend/data/data.json
|
||||
backend/cache/
|
||||
backend/data_seed/
|
||||
|
||||
# Build Logs
|
||||
*.log
|
||||
build_log*.txt
|
||||
|
||||
# Backup Files
|
||||
*_backup.*
|
||||
|
|
|
|||
|
|
@ -30,8 +30,9 @@ COPY frontend/ .
|
|||
# ENV NEXT_PUBLIC_API_URL="http://localhost:8000" Removed to use relative path proxying
|
||||
# Build Next.js
|
||||
ENV NEXTAUTH_URL=http://localhost:3000
|
||||
# Generate a static secret for now to prevent 500 error, or use a build arg
|
||||
ENV NEXTAUTH_SECRET=changeme_in_production_but_this_fixes_500_error
|
||||
# Secret should be provided at runtime via docker run -e or docker-compose
|
||||
ARG NEXTAUTH_SECRET_ARG=default_dev_secret_change_in_production
|
||||
ENV NEXTAUTH_SECRET=${NEXTAUTH_SECRET_ARG}
|
||||
RUN npm run build
|
||||
|
||||
# --- Final Setup ---
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
- **Smart Search**: Unified search across YouTube Music.
|
||||
- **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
|
||||
MIT License
|
||||
|
|
|
|||
|
|
@ -14,6 +14,58 @@ router = APIRouter()
|
|||
cache = CacheManager()
|
||||
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:
|
||||
if not text:
|
||||
return ""
|
||||
|
|
@ -92,6 +144,90 @@ async def get_browse_content():
|
|||
print(f"Browse Error: {e}")
|
||||
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")
|
||||
async def get_user_playlists():
|
||||
return playlist_manager.get_all()
|
||||
|
|
@ -134,12 +270,12 @@ async def get_playlist(id: str):
|
|||
playlist_data = None
|
||||
is_album = False
|
||||
|
||||
# Try as Album first if ID looks like an album (MPREb...) or just try block
|
||||
if id.startswith("MPREb"):
|
||||
try:
|
||||
playlist_data = yt.get_album(id)
|
||||
is_album = True
|
||||
except:
|
||||
except Exception as e:
|
||||
print(f"DEBUG: get_album(1) failed: {e}")
|
||||
pass
|
||||
|
||||
if not playlist_data:
|
||||
|
|
@ -147,24 +283,28 @@ async def get_playlist(id: str):
|
|||
# ytmusicapi returns a dict with 'tracks' list
|
||||
playlist_data = yt.get_playlist(id, limit=100)
|
||||
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
|
||||
if not is_album:
|
||||
try:
|
||||
playlist_data = yt.get_album(id)
|
||||
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
|
||||
|
||||
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
|
||||
formatted_tracks = []
|
||||
if 'tracks' in playlist_data:
|
||||
for track in playlist_data['tracks']:
|
||||
# Safely extract artists
|
||||
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"
|
||||
artist_names = extract_artist_names(track)
|
||||
|
||||
# Safely extract 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
|
||||
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
|
||||
album_info = track.get('album')
|
||||
# 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')
|
||||
album_name = extract_album_name(track, playlist_data.get('title', 'Single'))
|
||||
|
||||
formatted_tracks.append({
|
||||
"title": track.get('title', 'Unknown Title'),
|
||||
|
|
@ -191,13 +329,29 @@ async def get_playlist(id: str):
|
|||
|
||||
# Get Playlist Cover (usually highest res)
|
||||
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 = {
|
||||
"id": playlist_data.get('browseId', playlist_data.get('id')),
|
||||
"title": clean_title(playlist_data.get('title', 'Unknown')),
|
||||
"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,
|
||||
"tracks": formatted_tracks
|
||||
}
|
||||
|
|
@ -207,7 +361,15 @@ async def get_playlist(id: str):
|
|||
return formatted_playlist
|
||||
|
||||
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")
|
||||
|
||||
class UpdatePlaylistRequest(BaseModel):
|
||||
|
|
@ -261,20 +423,13 @@ async def search_tracks(query: str):
|
|||
|
||||
tracks = []
|
||||
for track in results:
|
||||
# Safely extract artists
|
||||
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"
|
||||
|
||||
artist_names = extract_artist_names(track)
|
||||
|
||||
# Safely extract 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_info = track.get('album')
|
||||
album_name = album_info.get('name', 'Single') if album_info else "Single"
|
||||
album_name = extract_album_name(track, "Single")
|
||||
|
||||
tracks.append({
|
||||
"title": track.get('title', 'Unknown Title'),
|
||||
|
|
@ -319,23 +474,21 @@ async def get_recommendations(seed_id: str = None):
|
|||
|
||||
tracks = []
|
||||
if 'tracks' in watch_playlist:
|
||||
seen_ids = set()
|
||||
seen_ids.add(seed_id)
|
||||
for track in watch_playlist['tracks']:
|
||||
# Skip the seed track itself if play history already has it
|
||||
if track.get('videoId') == seed_id:
|
||||
# Skip if seen or seed
|
||||
t_id = track.get('videoId')
|
||||
if not t_id or t_id in seen_ids:
|
||||
continue
|
||||
seen_ids.add(t_id)
|
||||
|
||||
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"
|
||||
artist_names = extract_artist_names(track)
|
||||
|
||||
thumbnails = track.get('thumbnails', [])
|
||||
cover_url = thumbnails[-1]['url'] if thumbnails else "https://placehold.co/300x300"
|
||||
thumbnails = track.get('thumbnails') or track.get('thumbnail') or []
|
||||
cover_url = get_high_res_thumbnail(thumbnails)
|
||||
|
||||
# album is often missing in watch playlist, fallback
|
||||
album_info = track.get('album')
|
||||
album_name = album_info.get('name', 'Single') if album_info else "Single"
|
||||
album_name = extract_album_name(track, "Single")
|
||||
|
||||
tracks.append({
|
||||
"title": track.get('title', 'Unknown Title'),
|
||||
|
|
@ -343,8 +496,8 @@ async def get_recommendations(seed_id: str = None):
|
|||
"album": album_name,
|
||||
"duration": track.get('length_seconds', track.get('duration_seconds', 0)),
|
||||
"cover_url": cover_url,
|
||||
"id": track.get('videoId'),
|
||||
"url": f"https://music.youtube.com/watch?v={track.get('videoId')}"
|
||||
"id": t_id,
|
||||
"url": f"https://music.youtube.com/watch?v={t_id}"
|
||||
})
|
||||
|
||||
response_data = {"tracks": tracks}
|
||||
|
|
@ -379,7 +532,7 @@ async def get_recommended_albums(seed_artist: str = None):
|
|||
albums = []
|
||||
for album in results:
|
||||
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({
|
||||
"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}")
|
||||
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")
|
||||
async def get_trending():
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -1,548 +1,449 @@
|
|||
{
|
||||
"id": "VLPLpY7hx7jry7zc4zspi_fBhWQt8z5jrJ8z",
|
||||
"title": "Best Vietnamese Songs 2026 - Popular Vietnamese Songs 2026 Playlist (Top Vietnamese Music 2026-2027)",
|
||||
"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",
|
||||
"cover_url": "https://yt3.googleusercontent.com/JaOpEjRt9S4wYkuVMkbu_2NLadP4vtUfQIpUlfob8mgB3CuoX8AsAJ24tAtbNRXGD2AkekLlbkiU=s1200",
|
||||
"id": "VLPLm_fM7dlkg8FbRVCCosRFtDldS74OVSgi",
|
||||
"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": "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/eEjGg43BajytnnS5S0gBc_rhXXhFLBLU-e8QF4jNMxX4W2oqmBhdd5uBzuTu11X5bRqpcX2hHw4=s1200",
|
||||
"tracks": [
|
||||
{
|
||||
"title": "Kh\u00f3a Ly Bi\u1ec7t (feat. Anh T\u00fa)",
|
||||
"artist": "The Masked Singer",
|
||||
"album": "Best Vietnamese Songs 2026 - Popular Vietnamese Songs 2026 Playlist (Top Vietnamese Music 2026-2027)",
|
||||
"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)",
|
||||
"title": "Who",
|
||||
"artist": "Jimin",
|
||||
"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": 208,
|
||||
"cover_url": "https://i.ytimg.com/vi/lRsaDQtYqAo/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3mvidflzqRlL9xdJeDUXZJg_UESRw",
|
||||
"id": "lRsaDQtYqAo",
|
||||
"url": "https://music.youtube.com/watch?v=lRsaDQtYqAo"
|
||||
"cover_url": "https://i.ytimg.com/vi/Av9DvtlJ9_M/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3lo74Sku93WaUE-8yH-iP6Zw_M7Uw",
|
||||
"id": "Av9DvtlJ9_M",
|
||||
"url": "https://music.youtube.com/watch?v=Av9DvtlJ9_M"
|
||||
},
|
||||
{
|
||||
"title": "m\u1ed9t \u0111\u1eddi (feat. buitruonglinh)",
|
||||
"artist": "Bon Nghi\u00eam, 14 Casper",
|
||||
"album": "Best Vietnamese Songs 2026 - Popular Vietnamese Songs 2026 Playlist (Top Vietnamese Music 2026-2027)",
|
||||
"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": "Seven (feat. Latto)",
|
||||
"artist": "Jung Kook",
|
||||
"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": 227,
|
||||
"cover_url": "https://i.ytimg.com/vi/QU9c0053UAU/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3ki23gt683kPaAs_tipdild1wrLTQ",
|
||||
"id": "QU9c0053UAU",
|
||||
"url": "https://music.youtube.com/watch?v=QU9c0053UAU"
|
||||
},
|
||||
{
|
||||
"title": "V\u00f9ng An To\u00e0n",
|
||||
"artist": "V#, B Ray",
|
||||
"album": "Best Vietnamese Songs 2026 - Popular Vietnamese Songs 2026 Playlist (Top Vietnamese Music 2026-2027)",
|
||||
"duration": 266,
|
||||
"cover_url": "https://i.ytimg.com/vi/_XX248bq6Pw/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3nKfVzeukJ8dCNJ_hzcyZAsvJ8upg",
|
||||
"id": "_XX248bq6Pw",
|
||||
"url": "https://music.youtube.com/watch?v=_XX248bq6Pw"
|
||||
"title": "M\u1ed8NG YU - AMEE x MCK | Official Music Video (from \u2018M\u1ed8NGMEE\u2019 album)",
|
||||
"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": 284,
|
||||
"cover_url": "https://i.ytimg.com/vi/09Mh7GgUFFA/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3kOfLMsTFC8tE0kNUwRjBLFPok5BQ",
|
||||
"id": "09Mh7GgUFFA",
|
||||
"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",
|
||||
"artist": "Qu\u00e2n A.P",
|
||||
"album": "Best Vietnamese Songs 2026 - Popular Vietnamese Songs 2026 Playlist (Top Vietnamese Music 2026-2027)",
|
||||
"duration": 319,
|
||||
"cover_url": "https://i.ytimg.com/vi/zHDECJy0p7k/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3nWDqcf0SVJtIipbWQqltt3cNu6eQ",
|
||||
"id": "zHDECJy0p7k",
|
||||
"url": "https://music.youtube.com/watch?v=zHDECJy0p7k"
|
||||
"title": "Die With A Smile",
|
||||
"artist": "Lady Gaga, Bruno Mars",
|
||||
"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": 253,
|
||||
"cover_url": "https://i.ytimg.com/vi/kPa7bsKwL-c/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3m0dtEFyfTFfbWA9Qj84zDLREtdUw",
|
||||
"id": "kPa7bsKwL-c",
|
||||
"url": "https://music.youtube.com/watch?v=kPa7bsKwL-c"
|
||||
},
|
||||
{
|
||||
"title": "b\u00ecnh y\u00ean - V\u0169. (feat. Binz)",
|
||||
"artist": "V\u0169., Binz",
|
||||
"album": "Best Vietnamese Songs 2026 - Popular Vietnamese Songs 2026 Playlist (Top Vietnamese Music 2026-2027)",
|
||||
"duration": 203,
|
||||
"cover_url": "https://i.ytimg.com/vi/f9P7_qWrf38/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3kI5gsa8Jegzzu2vFpJBhLk58mGeg",
|
||||
"id": "f9P7_qWrf38",
|
||||
"url": "https://music.youtube.com/watch?v=f9P7_qWrf38"
|
||||
"title": "Standing Next to You",
|
||||
"artist": "Jung Kook",
|
||||
"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": 227,
|
||||
"cover_url": "https://i.ytimg.com/vi/UNo0TG9LwwI/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3myS1MPOlFVYv7S9qH2lm9wln3RlQ",
|
||||
"id": "UNo0TG9LwwI",
|
||||
"url": "https://music.youtube.com/watch?v=UNo0TG9LwwI"
|
||||
},
|
||||
{
|
||||
"title": "n\u1ebfu l\u00fac \u0111\u00f3 (feat. 2pillz)",
|
||||
"artist": "Tlinh",
|
||||
"album": "Best Vietnamese Songs 2026 - Popular Vietnamese Songs 2026 Playlist (Top Vietnamese Music 2026-2027)",
|
||||
"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": "REGRET - LYRICS",
|
||||
"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/LWzxnYB8K08/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3m_mMidsikFjBFNkEKN7eK6Bl76iA",
|
||||
"id": "LWzxnYB8K08",
|
||||
"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",
|
||||
"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,
|
||||
"cover_url": "https://i.ytimg.com/vi/abPmZCZZrFA/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3nMzmdGlrfqmf8o9z-E5waTnqFXxA",
|
||||
"id": "abPmZCZZrFA",
|
||||
"url": "https://music.youtube.com/watch?v=abPmZCZZrFA"
|
||||
},
|
||||
{
|
||||
"title": "N\u1ed7i \u0110au Gi\u1eefa H\u00f2a B\u00ecnh",
|
||||
"artist": "H\u00f2a Minzy, Nguyen Van Chung",
|
||||
"album": "Best Vietnamese Songs 2026 - Popular Vietnamese Songs 2026 Playlist (Top Vietnamese Music 2026-2027)",
|
||||
"duration": 454,
|
||||
"cover_url": "https://i.ytimg.com/vi/yHikkFeIHNA/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3mhpsSG0tDGSBKkAK1X81aY1nrfgg",
|
||||
"id": "yHikkFeIHNA",
|
||||
"url": "https://music.youtube.com/watch?v=yHikkFeIHNA"
|
||||
"title": "H\u00c0O QUANG (feat. RHYDER, D\u01af\u01a0NG DOMIC & PH\u00c1P KI\u1ec0U)",
|
||||
"artist": "ANH TRAI \"SAY HI\"",
|
||||
"album": "T\u1eacP 5 - ANH TRAI \"SAY HI\"",
|
||||
"duration": 246,
|
||||
"cover_url": "https://lh3.googleusercontent.com/peH3Ubcoqxirb5EQxA-E0DkZAmQGZX5AiDpBA3Ow6sFUhNcfIAOLJbMzqpL8lGNBAFvEYoeD5xBt-lk=w120-h120-l90-rj",
|
||||
"id": "TMUWRIau4PU",
|
||||
"url": "https://music.youtube.com/watch?v=TMUWRIau4PU"
|
||||
},
|
||||
{
|
||||
"title": "10 Shots",
|
||||
"artist": "Dax",
|
||||
"album": "Best Vietnamese Songs 2026 - Popular Vietnamese Songs 2026 Playlist (Top Vietnamese Music 2026-2027)",
|
||||
"duration": 233,
|
||||
"cover_url": "https://i.ytimg.com/vi/0XMFwdfMQmQ/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3napt1cPSL4BTo7SSeDyrRUU7XF0Q",
|
||||
"id": "0XMFwdfMQmQ",
|
||||
"url": "https://music.youtube.com/watch?v=0XMFwdfMQmQ"
|
||||
"title": "Love Me Again",
|
||||
"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": 198,
|
||||
"cover_url": "https://i.ytimg.com/vi/HYzyRHAHJl8/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3nc6Ad1QlMZEkDRZyM8qr4euI4KtQ",
|
||||
"id": "HYzyRHAHJl8",
|
||||
"url": "https://music.youtube.com/watch?v=HYzyRHAHJl8"
|
||||
},
|
||||
{
|
||||
"title": "Ch\u0103m Hoa",
|
||||
"artist": "MONO",
|
||||
"album": "Best Vietnamese Songs 2026 - Popular Vietnamese Songs 2026 Playlist (Top Vietnamese Music 2026-2027)",
|
||||
"duration": 260,
|
||||
"cover_url": "https://i.ytimg.com/vi/WCm2elbTEZQ/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3kW5xCqL0V0Q9miffXVKmSRnn3S8A",
|
||||
"id": "WCm2elbTEZQ",
|
||||
"url": "https://music.youtube.com/watch?v=WCm2elbTEZQ"
|
||||
"title": "2 4 - w/n (3107 - 2024) (LYRICS)",
|
||||
"artist": "Nghe nh\u1ea1c Official",
|
||||
"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": 194,
|
||||
"cover_url": "https://i.ytimg.com/vi/M7KlePwgtE0/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3mR1KhgL-_ug9euybHCS0MFHfuuLA",
|
||||
"id": "M7KlePwgtE0",
|
||||
"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",
|
||||
"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,
|
||||
"cover_url": "https://i.ytimg.com/vi/leJb3VhQCrg/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3nWBTudc9VK3UqnpCgc_j8QYH3ugg",
|
||||
"id": "leJb3VhQCrg",
|
||||
"url": "https://music.youtube.com/watch?v=leJb3VhQCrg"
|
||||
},
|
||||
{
|
||||
"title": "Gi\u1edd Th\u00ec",
|
||||
"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)",
|
||||
"title": "Chuy\u1ec7n \u0110\u00f4i Ta - Emcee L (Da LAB) ft Mu\u1ed9ii (Official MV)",
|
||||
"artist": "Da LAB",
|
||||
"album": "Best Vietnamese Songs 2026 - Popular Vietnamese Songs 2026 Playlist (Top Vietnamese Music 2026-2027)",
|
||||
"duration": 291,
|
||||
"cover_url": "https://i.ytimg.com/vi/Z1D26z9l8y8/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3k34PODWHnu_p49YHu35__8V-4avw",
|
||||
"id": "Z1D26z9l8y8",
|
||||
"url": "https://music.youtube.com/watch?v=Z1D26z9l8y8"
|
||||
"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": 226,
|
||||
"cover_url": "https://i.ytimg.com/vi/6eONmnFB9sw/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3mVwjdT_-mZ2QAlwE7xqAKAQAVAlA",
|
||||
"id": "6eONmnFB9sw",
|
||||
"url": "https://music.youtube.com/watch?v=6eONmnFB9sw"
|
||||
},
|
||||
{
|
||||
"title": "C\u00e1nh Hoa H\u00e9o T\u00e0n (DJ Trang Moon Remix)",
|
||||
"artist": "ACV, Mochiii",
|
||||
"album": "Best Vietnamese Songs 2026 - Popular Vietnamese Songs 2026 Playlist (Top Vietnamese Music 2026-2027)",
|
||||
"duration": 265,
|
||||
"cover_url": "https://i.ytimg.com/vi/YizrmzMvr7Q/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3luIG3PhCNjJlZjuCRBwAKKrMPt9Q",
|
||||
"id": "YizrmzMvr7Q",
|
||||
"url": "https://music.youtube.com/watch?v=YizrmzMvr7Q"
|
||||
"title": "MI\u00caN MAN",
|
||||
"artist": "Minh Huy",
|
||||
"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": 205,
|
||||
"cover_url": "https://i.ytimg.com/vi/7uX_f8YzEiI/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3mNH-OfD4NzEJA1co0Lc1fikXUKpw",
|
||||
"id": "7uX_f8YzEiI",
|
||||
"url": "https://music.youtube.com/watch?v=7uX_f8YzEiI"
|
||||
},
|
||||
{
|
||||
"title": "SOOBIN - gi\u00e1 nh\u01b0 | 'B\u1eacT N\u00d3 L\u00caN' Album (Music Video)",
|
||||
"artist": "SOOBIN",
|
||||
"album": "Best Vietnamese Songs 2026 - Popular Vietnamese Songs 2026 Playlist (Top Vietnamese Music 2026-2027)",
|
||||
"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)",
|
||||
"title": "CH\u00daNG TA C\u1ee6A T\u01af\u01a0NG LAI",
|
||||
"artist": "S\u01a1n T\u00f9ng M-TP",
|
||||
"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": 277,
|
||||
"cover_url": "https://i.ytimg.com/vi/i4qZmKSFYvI/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3kLaE-0VAlEfGQRlKBACGiK0w0WDw",
|
||||
"id": "i4qZmKSFYvI",
|
||||
"url": "https://music.youtube.com/watch?v=i4qZmKSFYvI"
|
||||
"cover_url": "https://i.ytimg.com/vi/zoEtcR5EW08/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3lv4_jfNfESnK0mh8F5gKgJ7h1vUw",
|
||||
"id": "zoEtcR5EW08",
|
||||
"url": "https://music.youtube.com/watch?v=zoEtcR5EW08"
|
||||
},
|
||||
{
|
||||
"title": "N\u01a1i Ph\u00e1o Hoa R\u1ef1c R\u1ee1 (feat. C\u1ea9m V\u00e2n)",
|
||||
"artist": "Hua Kim Tuyen, Orange, Ho\u00e0ng D\u0169ng",
|
||||
"album": "Best Vietnamese Songs 2026 - Popular Vietnamese Songs 2026 Playlist (Top Vietnamese Music 2026-2027)",
|
||||
"duration": 310,
|
||||
"cover_url": "https://i.ytimg.com/vi/BgUFNi5MvzE/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3mGOmc3dRUaQczZnhubm_nb8Gs_Uw",
|
||||
"id": "BgUFNi5MvzE",
|
||||
"url": "https://music.youtube.com/watch?v=BgUFNi5MvzE"
|
||||
"title": "NOLOVENOLIFE",
|
||||
"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": 172,
|
||||
"cover_url": "https://i.ytimg.com/vi/F084mTHtBpI/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3mhKw7o_AGrSUJzBbFcDXst7hK2jA",
|
||||
"id": "F084mTHtBpI",
|
||||
"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",
|
||||
"album": "Best Vietnamese Songs 2026 - Popular Vietnamese Songs 2026 Playlist (Top Vietnamese Music 2026-2027)",
|
||||
"duration": 285,
|
||||
"cover_url": "https://i.ytimg.com/vi/X5KvHXWPYm4/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3lPWTFBiFDjAliZkS614MkwVcte1g",
|
||||
"id": "X5KvHXWPYm4",
|
||||
"url": "https://music.youtube.com/watch?v=X5KvHXWPYm4"
|
||||
"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": 334,
|
||||
"cover_url": "https://i.ytimg.com/vi/2YM4j-oP_qQ/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3lzpPjF9OCGDVvOwuwbLtyGa7Pi4A",
|
||||
"id": "2YM4j-oP_qQ",
|
||||
"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)",
|
||||
"artist": "Thi\u00ean T\u00fa",
|
||||
"album": "Best Vietnamese Songs 2026 - Popular Vietnamese Songs 2026 Playlist (Top Vietnamese Music 2026-2027)",
|
||||
"duration": 371,
|
||||
"cover_url": "https://i.ytimg.com/vi/IenfKDtyMI0/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3nbyyByYoJQ2qV7-2w4S6Gyofj9dQ",
|
||||
"id": "IenfKDtyMI0",
|
||||
"url": "https://music.youtube.com/watch?v=IenfKDtyMI0"
|
||||
"title": "WEAN \u2013 shhhhhhh.. feat tlinh (Official Lyrics Video)",
|
||||
"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": 238,
|
||||
"cover_url": "https://i.ytimg.com/vi/Pys2iOT9rpw/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3mXx9k9v4Z61a5lDYIb6s5wJ829XQ",
|
||||
"id": "Pys2iOT9rpw",
|
||||
"url": "https://music.youtube.com/watch?v=Pys2iOT9rpw"
|
||||
},
|
||||
{
|
||||
"title": "Nh\u1eefng L\u1eddi H\u1ee9a B\u1ecf Qu\u00ean",
|
||||
"artist": "V\u0169., Dear Jane",
|
||||
"album": "Best Vietnamese Songs 2026 - Popular Vietnamese Songs 2026 Playlist (Top Vietnamese Music 2026-2027)",
|
||||
"duration": 259,
|
||||
"cover_url": "https://i.ytimg.com/vi/h6RONxjPBf4/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3nfvRCueWOo-OjD8_3sK9HSlhvoSw",
|
||||
"id": "h6RONxjPBf4",
|
||||
"url": "https://music.youtube.com/watch?v=h6RONxjPBf4"
|
||||
"title": "HURRYKNG, HIEUTHUHAI, MANBO | H\u1eb9n G\u1eb7p Em D\u01b0\u1edbi \u00c1nh Tr\u0103ng | Official Video",
|
||||
"artist": "GERDNANG",
|
||||
"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": 232,
|
||||
"cover_url": "https://i.ytimg.com/vi/dLmczwDCEZI/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3kSMrrgkOYzN73RZZ9YG9WoUKG5xg",
|
||||
"id": "dLmczwDCEZI",
|
||||
"url": "https://music.youtube.com/watch?v=dLmczwDCEZI"
|
||||
},
|
||||
{
|
||||
"title": "m\u1ed9t b\u00e0i h\u00e1t kh\u00f4ng vui m\u1ea5y (Extended Version)",
|
||||
"artist": "T.R.I, Dangrangto, DONAL",
|
||||
"album": "Best Vietnamese Songs 2026 - Popular Vietnamese Songs 2026 Playlist (Top Vietnamese Music 2026-2027)",
|
||||
"duration": 230,
|
||||
"cover_url": "https://i.ytimg.com/vi/EvPEeSBfB3E/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3kvhX3tBQICPMgOEn0R9uswYvdC5A",
|
||||
"id": "EvPEeSBfB3E",
|
||||
"url": "https://music.youtube.com/watch?v=EvPEeSBfB3E"
|
||||
"title": "Obito - H\u00e0 N\u1ed9i ft. VSTRA",
|
||||
"artist": "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": 166,
|
||||
"cover_url": "https://i.ytimg.com/vi/OerAX-zKyvg/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3nO_yPfoJxp7C-FpHpc8p9sN47q1A",
|
||||
"id": "OerAX-zKyvg",
|
||||
"url": "https://music.youtube.com/watch?v=OerAX-zKyvg"
|
||||
},
|
||||
{
|
||||
"title": "One Time",
|
||||
"artist": "Raw Dawg",
|
||||
"album": "Best Vietnamese Songs 2026 - Popular Vietnamese Songs 2026 Playlist (Top Vietnamese Music 2026-2027)",
|
||||
"duration": 119,
|
||||
"cover_url": "https://i.ytimg.com/vi/ylh1oDhP2AE/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3lRAtyI5EucwyXxXGb9YLfFY2W6aQ",
|
||||
"id": "ylh1oDhP2AE",
|
||||
"url": "https://music.youtube.com/watch?v=ylh1oDhP2AE"
|
||||
"title": "LOVE SAND (feat. HIEUTHUHAI, JSOL, ALI HO\u00c0NG D\u01af\u01a0NG & V\u0168 TH\u1ecaNH)",
|
||||
"artist": "ANH TRAI \"SAY HI\"",
|
||||
"album": "T\u1eacP 4 - ANH TRAI \"SAY HI\"",
|
||||
"duration": 236,
|
||||
"cover_url": "https://lh3.googleusercontent.com/OWVfxVgRseYQVQcPzWcQ1bHhiYSfCxxiMqK5HDH7JXFdbRoo9RNr2-YbjdSwBGjk3Cz5l9DAetYOprVG=w120-h120-l90-rj",
|
||||
"id": "cSjF9UkTWqg",
|
||||
"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)",
|
||||
"artist": "MIN",
|
||||
"album": "Best Vietnamese Songs 2026 - Popular Vietnamese Songs 2026 Playlist (Top Vietnamese Music 2026-2027)",
|
||||
"duration": 284,
|
||||
"cover_url": "https://i.ytimg.com/vi/rLNvDu59ffI/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3nfhSKyeyGqrokp13H6G7C1rNvKLg",
|
||||
"id": "rLNvDu59ffI",
|
||||
"url": "https://music.youtube.com/watch?v=rLNvDu59ffI"
|
||||
},
|
||||
{
|
||||
"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": "An Th\u1ea7n (ft. Th\u1eafng) | Low G | Rap Nh\u00e0 L\u00e0m",
|
||||
"artist": "Rap Nh\u00e0 L\u00e0m",
|
||||
"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": 216,
|
||||
"cover_url": "https://i.ytimg.com/vi/J7eYhM6wXPo/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3kPN37KoE_QGfGty9ZPpJj2tYxa0A",
|
||||
"id": "J7eYhM6wXPo",
|
||||
"url": "https://music.youtube.com/watch?v=J7eYhM6wXPo"
|
||||
},
|
||||
{
|
||||
"title": "T\u1eebng quen",
|
||||
"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,
|
||||
"cover_url": "https://i.ytimg.com/vi/zepHPnUDROE/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3kQphjp4tiW4vFcaXJBk1wMtsk9Kg",
|
||||
"id": "zepHPnUDROE",
|
||||
"url": "https://music.youtube.com/watch?v=zepHPnUDROE"
|
||||
},
|
||||
{
|
||||
"title": "HOA B\u1ea4T T\u1eec",
|
||||
"artist": "Th\u00e0nh \u0110\u1ea1t",
|
||||
"album": "Best Vietnamese Songs 2026 - Popular Vietnamese Songs 2026 Playlist (Top Vietnamese Music 2026-2027)",
|
||||
"duration": 317,
|
||||
"cover_url": "https://i.ytimg.com/vi/n-k_aUsOaaQ/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3lfd3LBuB7aTSG880J0HqdjEqNQww",
|
||||
"id": "n-k_aUsOaaQ",
|
||||
"url": "https://music.youtube.com/watch?v=n-k_aUsOaaQ"
|
||||
"title": "T\u1eebng L\u00e0",
|
||||
"artist": "V\u0169 C\u00e1t T\u01b0\u1eddng",
|
||||
"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": 277,
|
||||
"cover_url": "https://i.ytimg.com/vi/i4qZmKSFYvI/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3kLaE-0VAlEfGQRlKBACGiK0w0WDw",
|
||||
"id": "i4qZmKSFYvI",
|
||||
"url": "https://music.youtube.com/watch?v=i4qZmKSFYvI"
|
||||
},
|
||||
{
|
||||
"title": "N\u00f3i D\u1ed1i | Ronboogz (Lyrics Video)",
|
||||
"artist": "Ronboogz",
|
||||
"album": "Best Vietnamese Songs 2026 - Popular Vietnamese Songs 2026 Playlist (Top Vietnamese Music 2026-2027)",
|
||||
"duration": 207,
|
||||
"cover_url": "https://i.ytimg.com/vi/ri-TFS97Hbw/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3lgdDfcXHekuevzN7qPIZR7RryanQ",
|
||||
"id": "ri-TFS97Hbw",
|
||||
"url": "https://music.youtube.com/watch?v=ri-TFS97Hbw"
|
||||
"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": "Negav",
|
||||
"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": 282,
|
||||
"cover_url": "https://i.ytimg.com/vi/WUbTGHzxeDI/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3njQTKSHjLv_2NRhqxzSlOiwryGAA",
|
||||
"id": "WUbTGHzxeDI",
|
||||
"url": "https://music.youtube.com/watch?v=WUbTGHzxeDI"
|
||||
},
|
||||
{
|
||||
"title": "MONO - \u2018Em Xinh\u2019 (Official Music Video)",
|
||||
"artist": "MONO",
|
||||
"album": "Best Vietnamese Songs 2026 - Popular Vietnamese Songs 2026 Playlist (Top Vietnamese Music 2026-2027)",
|
||||
"duration": 197,
|
||||
"cover_url": "https://i.ytimg.com/vi/rYc1UbgbMIY/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3mkHo5h-7KAI9SGhk2jG6m6cHospQ",
|
||||
"id": "rYc1UbgbMIY",
|
||||
"url": "https://music.youtube.com/watch?v=rYc1UbgbMIY"
|
||||
"title": "Nh\u1eefng L\u1eddi H\u1ee9a B\u1ecf Qu\u00ean",
|
||||
"artist": "V\u0169., Dear Jane",
|
||||
"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,
|
||||
"cover_url": "https://i.ytimg.com/vi/h6RONxjPBf4/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3nfvRCueWOo-OjD8_3sK9HSlhvoSw",
|
||||
"id": "h6RONxjPBf4",
|
||||
"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"
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -1,6 +1,6 @@
|
|||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from backend.api import routes
|
||||
from backend.api.routes import router as api_router
|
||||
import os
|
||||
|
||||
app = FastAPI(title="Spotify Clone Backend")
|
||||
|
|
@ -19,15 +19,11 @@ app.add_middleware(
|
|||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
app.include_router(routes.router, prefix="/api")
|
||||
app.include_router(api_router, prefix="/api")
|
||||
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.responses import FileResponse
|
||||
|
||||
# ... existing code ...
|
||||
|
||||
app.include_router(routes.router, prefix="/api")
|
||||
|
||||
# Serve Static Frontend (Production Mode)
|
||||
STATIC_DIR = "static"
|
||||
if os.path.exists(STATIC_DIR):
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
fastapi
|
||||
uvicorn
|
||||
fastapi==0.115.6
|
||||
uvicorn==0.34.0
|
||||
spotdl
|
||||
pydantic
|
||||
python-multipart
|
||||
requests
|
||||
yt-dlp
|
||||
ytmusicapi
|
||||
pydantic==2.10.4
|
||||
python-multipart==0.0.20
|
||||
requests==2.32.3
|
||||
yt-dlp==2024.12.23
|
||||
ytmusicapi==1.9.1
|
||||
syncedlyrics
|
||||
|
|
|
|||
17
debug_recs.py
Normal file
17
debug_recs.py
Normal 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
0
deploy_commands.sh
Executable file → Normal file
|
|
@ -9,8 +9,10 @@ yt = YTMusic()
|
|||
# Define diverse categories to fetch
|
||||
CATEGORIES = {
|
||||
"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"},
|
||||
"New Albums 2024": {"query": "New Albums 2024 Vietnam", "type": "albums"},
|
||||
"Chill Vibes": {"query": "Chill Lofi", "type": "playlists"},
|
||||
"Party Time": {"query": "Party EDM Hits", "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}) ---")
|
||||
|
||||
try:
|
||||
results = yt.search(query, filter=search_type, limit=5)
|
||||
results = yt.search(query, filter=search_type, limit=25)
|
||||
|
||||
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']
|
||||
title = result['title']
|
||||
print(f" > Processing: {title}")
|
||||
|
|
|
|||
271
frontend/app/artist/page.tsx
Normal file
271
frontend/app/artist/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -34,4 +34,38 @@ body {
|
|||
background: var(--spotify-base);
|
||||
color: var(--spotify-text-main);
|
||||
/* 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; }
|
||||
|
|
@ -4,6 +4,7 @@ import "./globals.css";
|
|||
import Sidebar from "@/components/Sidebar";
|
||||
import PlayerBar from "@/components/PlayerBar";
|
||||
import MobileNav from "@/components/MobileNav";
|
||||
import RightSidebar from "@/components/RightSidebar";
|
||||
import { PlayerProvider } from "@/context/PlayerContext";
|
||||
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">
|
||||
{children}
|
||||
</main>
|
||||
<RightSidebar />
|
||||
</div>
|
||||
<PlayerBar />
|
||||
<MobileNav />
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { useLibrary } from "@/context/LibraryContext";
|
|||
import Link from "next/link";
|
||||
import { Plus } from "lucide-react";
|
||||
import CreatePlaylistModal from "@/components/CreatePlaylistModal";
|
||||
import CoverImage from "@/components/CoverImage";
|
||||
|
||||
export default function LibraryPage() {
|
||||
const { userPlaylists: playlists, libraryItems, refreshLibrary: refresh, activeFilter: activeTab, setActiveFilter: setActiveTab } = useLibrary();
|
||||
|
|
@ -61,7 +62,7 @@ export default function LibraryPage() {
|
|||
</button>
|
||||
</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 */}
|
||||
{showPlaylists && (
|
||||
<>
|
||||
|
|
@ -74,36 +75,34 @@ export default function LibraryPage() {
|
|||
|
||||
{playlists.map((playlist) => (
|
||||
<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="aspect-square w-full mb-3 overflow-hidden rounded-md bg-[#282828] shadow-lg">
|
||||
{playlist.cover_url && !playlist.cover_url.includes("placehold") ? (
|
||||
<img src={playlist.cover_url} alt={playlist.title} className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center bg-[#333]">
|
||||
<span className="text-2xl">🎵</span>
|
||||
</div>
|
||||
)}
|
||||
<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-2 md:mb-3 overflow-hidden rounded-md shadow-lg">
|
||||
<CoverImage
|
||||
src={playlist.cover_url}
|
||||
alt={playlist.title}
|
||||
className="w-full h-full object-cover"
|
||||
fallbackText={playlist.title?.substring(0, 2).toUpperCase()}
|
||||
/>
|
||||
</div>
|
||||
<h3 className="text-white font-bold text-sm truncate">{playlist.title}</h3>
|
||||
<p className="text-[#a7a7a7] text-xs">Playlist • You</p>
|
||||
<h3 className="text-white font-bold text-xs md:text-sm truncate">{playlist.title}</h3>
|
||||
<p className="text-[#a7a7a7] text-[10px] md:text-xs">Playlist • You</p>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
|
||||
{browsePlaylists.map((playlist) => (
|
||||
<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="aspect-square w-full mb-3 overflow-hidden rounded-md bg-[#282828] shadow-lg">
|
||||
{playlist.cover_url && !playlist.cover_url.includes("placehold") ? (
|
||||
<img src={playlist.cover_url} alt={playlist.title} className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center bg-[#333]">
|
||||
<span className="text-2xl">🎵</span>
|
||||
</div>
|
||||
)}
|
||||
<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-2 md:mb-3 overflow-hidden rounded-md shadow-lg">
|
||||
<CoverImage
|
||||
src={playlist.cover_url}
|
||||
alt={playlist.title}
|
||||
className="w-full h-full object-cover"
|
||||
fallbackText={playlist.title?.substring(0, 2).toUpperCase()}
|
||||
/>
|
||||
</div>
|
||||
<h3 className="text-white font-bold text-sm truncate">{playlist.title}</h3>
|
||||
<p className="text-[#a7a7a7] text-xs">Playlist • Made for you</p>
|
||||
<h3 className="text-white font-bold text-xs md:text-sm truncate">{playlist.title}</h3>
|
||||
<p className="text-[#a7a7a7] text-[10px] md:text-xs">Playlist • Made for you</p>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
|
|
@ -112,27 +111,18 @@ export default function LibraryPage() {
|
|||
|
||||
{/* Artists Content (Circular Images) */}
|
||||
{showArtists && artists.map((artist) => (
|
||||
<Link href={`/playlist?id=${artist.id}`} 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="aspect-square w-full mb-3 overflow-hidden rounded-full bg-[#282828] shadow-lg relative">
|
||||
{artist.cover_url ? (
|
||||
<img
|
||||
src={artist.cover_url}
|
||||
alt={artist.title}
|
||||
className="w-full h-full object-cover"
|
||||
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>
|
||||
<Link href={`/artist?name=${encodeURIComponent(artist.title)}`} key={artist.id}>
|
||||
<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-2 md:mb-3 overflow-hidden rounded-full shadow-lg">
|
||||
<CoverImage
|
||||
src={artist.cover_url}
|
||||
alt={artist.title}
|
||||
className="w-full h-full object-cover rounded-full"
|
||||
fallbackText={artist.title?.substring(0, 2).toUpperCase()}
|
||||
/>
|
||||
</div>
|
||||
<h3 className="text-white font-bold text-sm truncate w-full">{artist.title}</h3>
|
||||
<p className="text-[#a7a7a7] text-xs">Artist</p>
|
||||
<h3 className="text-white font-bold text-xs md:text-sm truncate w-full">{artist.title}</h3>
|
||||
<p className="text-[#a7a7a7] text-[10px] md:text-xs">Artist</p>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
|
|
@ -140,28 +130,17 @@ export default function LibraryPage() {
|
|||
{/* Albums Content */}
|
||||
{showAlbums && albums.map((album) => (
|
||||
<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="aspect-square w-full mb-3 overflow-hidden rounded-md bg-[#282828] shadow-lg relative">
|
||||
{album.cover_url ? (
|
||||
<img
|
||||
src={album.cover_url}
|
||||
alt={album.title}
|
||||
className="w-full h-full object-cover"
|
||||
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 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-2 md:mb-3 overflow-hidden rounded-md shadow-lg">
|
||||
<CoverImage
|
||||
src={album.cover_url}
|
||||
alt={album.title}
|
||||
className="w-full h-full object-cover"
|
||||
fallbackText={album.title?.substring(0, 2).toUpperCase()}
|
||||
/>
|
||||
</div>
|
||||
<h3 className="text-white font-bold text-sm truncate">{album.title}</h3>
|
||||
<p className="text-[#a7a7a7] text-xs">Album • {album.creator || 'Spotify'}</p>
|
||||
<h3 className="text-white font-bold text-xs md:text-sm truncate">{album.title}</h3>
|
||||
<p className="text-[#a7a7a7] text-[10px] md:text-xs">Album • {album.creator || 'Spotify'}</p>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -1,14 +1,22 @@
|
|||
"use client";
|
||||
|
||||
import { Play, Pause } from "lucide-react";
|
||||
import { Play, Pause, ArrowUpDown, Clock, Music2, User } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { usePlayer } from "@/context/PlayerContext";
|
||||
import Link from "next/link";
|
||||
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() {
|
||||
const [timeOfDay, setTimeOfDay] = useState("Good evening");
|
||||
const [browseData, setBrowseData] = useState<Record<string, any[]>>({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [sortBy, setSortBy] = useState<SortOption>('recent');
|
||||
const [showSortMenu, setShowSortMenu] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const hour = new Date().getHours();
|
||||
|
|
@ -17,120 +25,234 @@ export default function Home() {
|
|||
else setTimeOfDay("Good evening");
|
||||
|
||||
// Fetch Browse Content
|
||||
setLoading(true);
|
||||
libraryService.getBrowseContent()
|
||||
.then(data => setBrowseData(data))
|
||||
.catch(err => console.error("Error fetching browse:", err));
|
||||
.then(data => {
|
||||
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
|
||||
const firstCategory = Object.keys(browseData)[0];
|
||||
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 (
|
||||
<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">
|
||||
<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>
|
||||
|
||||
{/* Hero Section (First Playlist) */}
|
||||
{heroPlaylist && (
|
||||
{/* Hero Section (Big Playlist Banner - AT THE TOP) */}
|
||||
{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}`}>
|
||||
<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">
|
||||
<img
|
||||
src={heroPlaylist.cover_url || "https://placehold.co/200"}
|
||||
alt={heroPlaylist.title}
|
||||
className="w-48 h-48 md:w-60 md:h-60 shadow-2xl object-cover rounded-md"
|
||||
/>
|
||||
<div className="flex flex-col justify-end w-full">
|
||||
<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>
|
||||
<p className="text-sm font-medium mb-4 text-[#a7a7a7] line-clamp-2">{heroPlaylist.description}</p>
|
||||
<div className="flex items-center justify-center md:justify-start gap-4">
|
||||
<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">
|
||||
<Play className="fill-black text-black ml-1 w-5 h-5 md:w-6 md:h-6" />
|
||||
</div>
|
||||
<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">
|
||||
<div className="relative mb-4 md:mb-0 md:mr-8 flex-shrink-0">
|
||||
<CoverImage
|
||||
src={heroPlaylist.cover_url}
|
||||
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"
|
||||
fallbackText="VB"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col text-center md:text-left">
|
||||
<span className="text-xs font-bold tracking-wider uppercase mb-2">Featured Playlist</span>
|
||||
<h2 className="text-3xl md:text-5xl font-black mb-4 leading-tight">{heroPlaylist.title}</h2>
|
||||
<p className="text-[#a7a7a7] text-sm md:text-base line-clamp-2 md:line-clamp-3 max-w-2xl mb-6">
|
||||
{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>
|
||||
</Link>
|
||||
)}
|
||||
|
||||
{/* Made For You (Recommendations) */}
|
||||
{/* Made For You Section (Recommendations) */}
|
||||
<MadeForYouSection />
|
||||
|
||||
{/* Recommended Albums */}
|
||||
{/* Artist Section (Vietnam) */}
|
||||
<ArtistVietnamSection />
|
||||
|
||||
{/* Dynamic Recommended Albums based on history */}
|
||||
<RecommendedAlbumsSection />
|
||||
|
||||
{/* Render Categories */}
|
||||
{Object.entries(browseData).map(([category, playlists]) => (
|
||||
<div key={category} className="mb-8">
|
||||
<h2 className="text-2xl font-bold mb-4 hover:underline cursor-pointer">{category}</h2>
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-6">
|
||||
{playlists.slice(0, 5).map((playlist) => (
|
||||
<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">
|
||||
<img
|
||||
src={playlist.cover_url || "https://placehold.co/200"}
|
||||
alt={playlist.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" />
|
||||
{/* Recently Listened */}
|
||||
<RecentlyListenedSection />
|
||||
|
||||
{/* Main Browse Lists */}
|
||||
{loading ? (
|
||||
<div className="space-y-8">
|
||||
{[1, 2].map(i => (
|
||||
<div key={i}>
|
||||
<Skeleton className="h-8 w-48 mb-4" />
|
||||
<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(j => (
|
||||
<div key={j} 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>
|
||||
))}
|
||||
</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>
|
||||
<h3 className="font-bold mb-1 truncate">{playlist.title}</h3>
|
||||
<p className="text-sm text-[#a7a7a7] line-clamp-2">{playlist.description}</p>
|
||||
</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>
|
||||
))}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
function MadeForYouSection() {
|
||||
// NEW: Recently Listened Section - Pinned to top
|
||||
function RecentlyListenedSection() {
|
||||
const { playHistory, playTrack } = usePlayer();
|
||||
const [recommendations, setRecommendations] = useState<any[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
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;
|
||||
if (playHistory.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="mb-8 animate-in fade-in duration-500">
|
||||
<h2 className="text-2xl font-bold mb-4">Made For You</h2>
|
||||
<p className="text-sm text-[#a7a7a7] mb-4">Based on your listening of <span className="text-white font-medium">{playHistory[0].title}</span></p>
|
||||
<div className="grid grid-cols-2 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">
|
||||
<img
|
||||
<div className="mb-8 animate-in fade-in duration-300">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<Clock className="w-5 h-5 text-[#1DB954]" />
|
||||
<h2 className="text-2xl font-bold">Recently Listened</h2>
|
||||
</div>
|
||||
|
||||
{/* Horizontal Scrollable Row */}
|
||||
<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}
|
||||
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="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 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 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 mb-1 truncate">{track.title}</h3>
|
||||
<p className="text-sm text-[#a7a7a7] line-clamp-2">{track.artist}</p>
|
||||
<div className="p-3">
|
||||
<h3 className="font-medium text-sm truncate">{track.title}</h3>
|
||||
<p className="text-xs text-[#a7a7a7] truncate">{track.artist}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
|
@ -138,40 +260,69 @@ function MadeForYouSection() {
|
|||
);
|
||||
}
|
||||
|
||||
function RecommendedAlbumsSection() {
|
||||
const { playHistory } = usePlayer();
|
||||
const [albums, setAlbums] = useState<any[]>([]);
|
||||
function MadeForYouSection() {
|
||||
const { playHistory, playTrack } = usePlayer();
|
||||
const [recommendations, setRecommendations] = useState<Track[]>([]);
|
||||
const [seedTrack, setSeedTrack] = useState<Track | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (playHistory.length > 0) {
|
||||
const seedArtist = playHistory[0].artist; // Last played artist
|
||||
if (!seedArtist) return;
|
||||
const seed = playHistory[0]; // Last played
|
||||
setSeedTrack(seed);
|
||||
setLoading(true);
|
||||
|
||||
// Clean artist name (remove delimiters like commas if multiple)
|
||||
const primaryArtist = seedArtist.split(',')[0].trim();
|
||||
|
||||
libraryService.getRecommendedAlbums(primaryArtist)
|
||||
// Fetch actual recommendations from backend
|
||||
fetch(`/api/recommendations?seed_id=${seed.id}`)
|
||||
.then(res => res.json())
|
||||
.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 (
|
||||
<div className="mb-8 animate-in fade-in duration-700">
|
||||
<h2 className="text-2xl font-bold mb-4">Recommended Albums</h2>
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 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="mb-8 animate-in fade-in duration-500">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Music2 className="w-5 h-5 text-[#1DB954]" />
|
||||
<h2 className="text-2xl font-bold">Made For You</h2>
|
||||
</div>
|
||||
<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">
|
||||
<img
|
||||
src={album.cover_url}
|
||||
alt={album.title}
|
||||
<CoverImage
|
||||
src={track.cover_url}
|
||||
alt={track.title}
|
||||
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="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>
|
||||
<h3 className="font-bold mb-1 truncate">{album.title}</h3>
|
||||
<p className="text-sm text-[#a7a7a7] line-clamp-2">{album.description}</p>
|
||||
<h3 className="font-bold mb-1 truncate">{track.title}</h3>
|
||||
<p className="text-sm text-[#a7a7a7] line-clamp-2">{track.artist}</p>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
172
frontend/app/section/page.tsx
Normal file
172
frontend/app/section/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
76
frontend/components/CoverImage.tsx
Normal file
76
frontend/components/CoverImage.tsx
Normal 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);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
60
frontend/components/Logo.tsx
Normal file
60
frontend/components/Logo.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -10,9 +10,10 @@ interface LyricsDetailProps {
|
|||
currentTime: number;
|
||||
onClose: () => 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 [isLoading, setIsLoading] = useState(false);
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||
|
|
@ -78,24 +79,26 @@ const LyricsDetail: React.FC<LyricsDetailProps> = ({ track, currentTime, onClose
|
|||
if (!track) return null;
|
||||
|
||||
return (
|
||||
<div className={`absolute inset-0 flex flex-col bg-transparent text-white`}>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-6 bg-gradient-to-b from-black/80 to-transparent z-10">
|
||||
<div className="flex-1 min-w-0">
|
||||
<h2 className="text-xl font-bold truncate">Lyrics</h2>
|
||||
<p className="text-white/60 text-xs truncate uppercase tracking-widest">
|
||||
{track.artist}
|
||||
</p>
|
||||
<div className={`${isInSidebar ? 'relative h-full' : 'absolute inset-0'} flex flex-col bg-transparent text-white`}>
|
||||
{/* Header - only show when NOT in sidebar */}
|
||||
{!isInSidebar && (
|
||||
<div className="flex items-center justify-between p-6 bg-gradient-to-b from-black/80 to-transparent z-10">
|
||||
<div className="flex-1 min-w-0">
|
||||
<h2 className="text-xl font-bold truncate">Lyrics</h2>
|
||||
<p className="text-white/60 text-xs truncate uppercase tracking-widest">
|
||||
{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>
|
||||
<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 */}
|
||||
<div
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import AddToPlaylistModal from "@/components/AddToPlaylistModal";
|
|||
import LyricsDetail from './LyricsDetail';
|
||||
|
||||
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 [progress, setProgress] = useState(0);
|
||||
const [duration, setDuration] = useState(0);
|
||||
|
|
@ -17,7 +17,7 @@ export default function PlayerBar() {
|
|||
|
||||
// Modal State
|
||||
const [isAddToPlaylistOpen, setIsAddToPlaylistOpen] = useState(false);
|
||||
const [isLyricsOpen, setIsLyricsOpen] = useState(false);
|
||||
// isLyricsOpen is now in context
|
||||
const [isTechSpecsOpen, setIsTechSpecsOpen] = useState(false);
|
||||
const [isFullScreenPlayerOpen, setIsFullScreenPlayerOpen] = useState(false);
|
||||
const [isCoverModalOpen, setIsCoverModalOpen] = useState(false);
|
||||
|
|
@ -145,6 +145,8 @@ export default function PlayerBar() {
|
|||
return `${minutes}:${seconds.toString().padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
if (!currentTrack) return null;
|
||||
|
||||
return (
|
||||
<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"
|
||||
|
|
@ -252,7 +254,7 @@ export default function PlayerBar() {
|
|||
{/* Mobile Lyrics Button */}
|
||||
<button
|
||||
className={`transition ${isLyricsOpen ? 'text-green-500' : 'text-neutral-300'}`}
|
||||
onClick={(e) => { e.stopPropagation(); setIsLyricsOpen(!isLyricsOpen); }}
|
||||
onClick={(e) => { e.stopPropagation(); toggleLyrics(); }}
|
||||
>
|
||||
<Mic2 size={22} />
|
||||
</button>
|
||||
|
|
@ -316,7 +318,7 @@ export default function PlayerBar() {
|
|||
<div className="flex items-center justify-end space-x-2 md:space-x-4">
|
||||
<button
|
||||
className={`text-zinc-400 hover:text-white transition ${isLyricsOpen ? 'text-green-500' : ''}`}
|
||||
onClick={() => setIsLyricsOpen(!isLyricsOpen)}
|
||||
onClick={() => toggleLyrics()}
|
||||
title="Lyrics"
|
||||
>
|
||||
<Mic2 size={20} />
|
||||
|
|
@ -431,7 +433,7 @@ export default function PlayerBar() {
|
|||
</button>
|
||||
<button
|
||||
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} />
|
||||
</button>
|
||||
|
|
@ -467,19 +469,13 @@ export default function PlayerBar() {
|
|||
/>
|
||||
)}
|
||||
|
||||
{/* Lyrics Sheet (Responsive) */}
|
||||
{/* Lyrics Sheet (Mobile Only - Desktop uses Right Sidebar) */}
|
||||
{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">
|
||||
{/* 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)}
|
||||
/> */}
|
||||
|
||||
<div className="fixed inset-0 z-[70] bg-[#121212] flex flex-col md:hidden animate-in slide-in-from-bottom-full duration-300">
|
||||
<LyricsDetail
|
||||
track={currentTrack}
|
||||
currentTime={audioRef.current ? audioRef.current.currentTime : 0}
|
||||
onClose={() => setIsLyricsOpen(false)}
|
||||
onClose={() => toggleLyrics()}
|
||||
onSeek={(time) => {
|
||||
if (audioRef.current) {
|
||||
audioRef.current.currentTime = time;
|
||||
|
|
|
|||
62
frontend/components/RightSidebar.tsx
Normal file
62
frontend/components/RightSidebar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -7,6 +7,8 @@ import { useState } from "react";
|
|||
import CreatePlaylistModal from "./CreatePlaylistModal";
|
||||
import { dbService } from "@/services/db";
|
||||
import { useLibrary } from "@/context/LibraryContext";
|
||||
import Logo from "./Logo";
|
||||
import CoverImage from "./CoverImage";
|
||||
|
||||
export default function Sidebar() {
|
||||
const { likedTracks } = usePlayer();
|
||||
|
|
@ -39,9 +41,9 @@ export default function Sidebar() {
|
|||
return (
|
||||
<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">
|
||||
{/* Logo replaces Home link */}
|
||||
<Link href="/" className="flex items-center gap-4 text-spotify-text-muted hover:text-white transition cursor-pointer">
|
||||
<Home className="w-6 h-6" />
|
||||
<span className="font-bold">Home</span>
|
||||
<Logo />
|
||||
</Link>
|
||||
<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" />
|
||||
|
|
@ -100,13 +102,11 @@ export default function Sidebar() {
|
|||
{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">
|
||||
<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">
|
||||
{playlist.cover_url && !playlist.cover_url.includes("placehold") ? (
|
||||
<img src={playlist.cover_url} alt="" className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<span className="text-xl">🎵</span>
|
||||
)}
|
||||
</div>
|
||||
<CoverImage
|
||||
src={playlist.cover_url}
|
||||
alt={playlist.title || ''}
|
||||
className="w-12 h-12 rounded object-cover"
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-white font-medium truncate">{playlist.title}</h3>
|
||||
<p className="text-sm text-spotify-text-muted truncate">Playlist • You</p>
|
||||
|
|
@ -128,13 +128,11 @@ export default function Sidebar() {
|
|||
{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">
|
||||
<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">
|
||||
{playlist.cover_url && !playlist.cover_url.includes("placehold") ? (
|
||||
<img src={playlist.cover_url} alt="" className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<span className="text-xl">🎵</span>
|
||||
)}
|
||||
</div>
|
||||
<CoverImage
|
||||
src={playlist.cover_url}
|
||||
alt={playlist.title || ''}
|
||||
className="w-12 h-12 rounded object-cover"
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-white font-medium truncate">{playlist.title}</h3>
|
||||
<p className="text-sm text-spotify-text-muted truncate">Playlist • Made for you</p>
|
||||
|
|
@ -145,13 +143,14 @@ export default function Sidebar() {
|
|||
|
||||
{/* Artists */}
|
||||
{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="w-12 h-12 bg-[#282828] rounded-full flex items-center justify-center overflow-hidden relative">
|
||||
{artist.cover_url ? (
|
||||
<img src={artist.cover_url} alt="" className="w-full h-full object-cover" onError={(e) => e.currentTarget.style.display = 'none'} />
|
||||
) : null}
|
||||
</div>
|
||||
<CoverImage
|
||||
src={artist.cover_url}
|
||||
alt={artist.title}
|
||||
className="w-12 h-12 rounded-full object-cover"
|
||||
fallbackText={artist.title?.substring(0, 2).toUpperCase()}
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-white font-medium truncate">{artist.title}</h3>
|
||||
<p className="text-sm text-spotify-text-muted truncate">Artist</p>
|
||||
|
|
|
|||
13
frontend/components/Skeleton.tsx
Normal file
13
frontend/components/Skeleton.tsx
Normal 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}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -2,27 +2,9 @@
|
|||
|
||||
import { createContext, useContext, useState, useEffect, ReactNode } from "react";
|
||||
import { dbService } from "@/services/db";
|
||||
|
||||
interface Track {
|
||||
title: string;
|
||||
artist: string;
|
||||
album: string;
|
||||
cover_url: string;
|
||||
id: string;
|
||||
url?: string;
|
||||
}
|
||||
|
||||
import { Track, AudioQuality } from "@/types";
|
||||
import * as mm from 'music-metadata-browser';
|
||||
|
||||
interface AudioQuality {
|
||||
format: string;
|
||||
sampleRate: number;
|
||||
bitDepth?: number;
|
||||
bitrate: number;
|
||||
channels: number;
|
||||
codec?: string;
|
||||
}
|
||||
|
||||
interface PlayerContextType {
|
||||
currentTrack: Track | null;
|
||||
isPlaying: boolean;
|
||||
|
|
@ -41,6 +23,9 @@ interface PlayerContextType {
|
|||
toggleLike: (track: Track) => void;
|
||||
playHistory: Track[];
|
||||
audioQuality: AudioQuality | null;
|
||||
// Lyrics panel state
|
||||
isLyricsOpen: boolean;
|
||||
toggleLyrics: () => void;
|
||||
}
|
||||
|
||||
const PlayerContext = createContext<PlayerContextType | undefined>(undefined);
|
||||
|
|
@ -65,6 +50,10 @@ export function PlayerProvider({ children }: { children: ReactNode }) {
|
|||
// History State
|
||||
const [playHistory, setPlayHistory] = useState<Track[]>([]);
|
||||
|
||||
// Lyrics Panel State
|
||||
const [isLyricsOpen, setIsLyricsOpen] = useState(false);
|
||||
const toggleLyrics = () => setIsLyricsOpen(prev => !prev);
|
||||
|
||||
// Load Likes from DB
|
||||
useEffect(() => {
|
||||
dbService.getLikedSongs().then(tracks => {
|
||||
|
|
@ -272,7 +261,9 @@ export function PlayerProvider({ children }: { children: ReactNode }) {
|
|||
setBuffering,
|
||||
toggleLike,
|
||||
playHistory,
|
||||
audioQuality
|
||||
audioQuality,
|
||||
isLyricsOpen,
|
||||
toggleLyrics
|
||||
}}>
|
||||
{children}
|
||||
</PlayerContext.Provider>
|
||||
|
|
|
|||
|
|
@ -13,12 +13,15 @@ const nextConfig = {
|
|||
// 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/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/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/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/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/recommendations/:path*', destination: 'http://127.0.0.1:8000/api/recommendations/:path*' },
|
||||
];
|
||||
},
|
||||
images: {
|
||||
|
|
|
|||
523
frontend/package-lock.json
generated
523
frontend/package-lock.json
generated
|
|
@ -22,7 +22,7 @@
|
|||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"eslint": "^9",
|
||||
"eslint": "^8",
|
||||
"eslint-config-next": "^14.2.0",
|
||||
"tailwindcss": "^3.4.1",
|
||||
"typescript": "^5"
|
||||
|
|
@ -94,19 +94,6 @@
|
|||
"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": {
|
||||
"version": "4.12.2",
|
||||
"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_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": {
|
||||
"version": "3.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.3.tgz",
|
||||
"integrity": "sha512-Kr+LPIUVKz2qkx1HAMH8q1q6azbqBAsXJUxBl/ODDuVPX45Z9DfwB8tPjTi6nNZ8BuM3nbJxC5zCAg5elnBUTQ==",
|
||||
"version": "2.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.4.tgz",
|
||||
"integrity": "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ajv": "^6.12.4",
|
||||
"debug": "^4.3.2",
|
||||
"espree": "^10.0.1",
|
||||
"globals": "^14.0.0",
|
||||
"espree": "^9.6.0",
|
||||
"globals": "^13.19.0",
|
||||
"ignore": "^5.2.0",
|
||||
"import-fresh": "^3.2.1",
|
||||
"js-yaml": "^4.1.1",
|
||||
"js-yaml": "^4.1.0",
|
||||
"minimatch": "^3.1.2",
|
||||
"strip-json-comments": "^3.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/eslint"
|
||||
}
|
||||
},
|
||||
"node_modules/@eslint/js": {
|
||||
"version": "9.39.2",
|
||||
"resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.2.tgz",
|
||||
"integrity": "sha512-q1mjIoW1VX4IvSocvM/vbTiveKC4k9eLrajNEuSsmjymSDEbpGddtpfOoN7YGAqBK3NG+uqo8ia4PDTt8buCYA==",
|
||||
"version": "8.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.1.tgz",
|
||||
"integrity": "sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://eslint.org/donate"
|
||||
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@eslint/object-schema": {
|
||||
"version": "2.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz",
|
||||
"integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==",
|
||||
"dev": true,
|
||||
"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==",
|
||||
"node_modules/@humanwhocodes/config-array": {
|
||||
"version": "0.13.0",
|
||||
"resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz",
|
||||
"integrity": "sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==",
|
||||
"deprecated": "Use @eslint/config-array instead",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@eslint/core": "^0.17.0",
|
||||
"levn": "^0.4.1"
|
||||
"@humanwhocodes/object-schema": "^2.0.3",
|
||||
"debug": "^4.3.1",
|
||||
"minimatch": "^3.0.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.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": ">=10.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@humanwhocodes/module-importer": {
|
||||
|
|
@ -257,19 +168,13 @@
|
|||
"url": "https://github.com/sponsors/nzakas"
|
||||
}
|
||||
},
|
||||
"node_modules/@humanwhocodes/retry": {
|
||||
"version": "0.4.3",
|
||||
"resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz",
|
||||
"integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==",
|
||||
"node_modules/@humanwhocodes/object-schema": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.3.tgz",
|
||||
"integrity": "sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==",
|
||||
"deprecated": "Use @eslint/object-schema instead",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=18.18"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/nzakas"
|
||||
}
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/@isaacs/cliui": {
|
||||
"version": "8.0.2",
|
||||
|
|
@ -903,20 +808,6 @@
|
|||
"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": {
|
||||
"version": "0.0.29",
|
||||
"resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz",
|
||||
|
|
@ -1088,18 +979,12 @@
|
|||
"url": "https://opencollective.com/typescript-eslint"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/visitor-keys/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==",
|
||||
"node_modules/@ungap/structured-clone": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz",
|
||||
"integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/eslint"
|
||||
}
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/@unrs/resolver-binding-android-arm-eabi": {
|
||||
"version": "1.11.1",
|
||||
|
|
@ -1472,6 +1357,19 @@
|
|||
"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": {
|
||||
"version": "5.0.2",
|
||||
"resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz",
|
||||
|
|
@ -2548,63 +2446,60 @@
|
|||
}
|
||||
},
|
||||
"node_modules/eslint": {
|
||||
"version": "9.39.2",
|
||||
"resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.2.tgz",
|
||||
"integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==",
|
||||
"version": "8.57.1",
|
||||
"resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.1.tgz",
|
||||
"integrity": "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==",
|
||||
"deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.8.0",
|
||||
"@eslint-community/regexpp": "^4.12.1",
|
||||
"@eslint/config-array": "^0.21.1",
|
||||
"@eslint/config-helpers": "^0.4.2",
|
||||
"@eslint/core": "^0.17.0",
|
||||
"@eslint/eslintrc": "^3.3.1",
|
||||
"@eslint/js": "9.39.2",
|
||||
"@eslint/plugin-kit": "^0.4.1",
|
||||
"@humanfs/node": "^0.16.6",
|
||||
"@eslint-community/eslint-utils": "^4.2.0",
|
||||
"@eslint-community/regexpp": "^4.6.1",
|
||||
"@eslint/eslintrc": "^2.1.4",
|
||||
"@eslint/js": "8.57.1",
|
||||
"@humanwhocodes/config-array": "^0.13.0",
|
||||
"@humanwhocodes/module-importer": "^1.0.1",
|
||||
"@humanwhocodes/retry": "^0.4.2",
|
||||
"@types/estree": "^1.0.6",
|
||||
"@nodelib/fs.walk": "^1.2.8",
|
||||
"@ungap/structured-clone": "^1.2.0",
|
||||
"ajv": "^6.12.4",
|
||||
"chalk": "^4.0.0",
|
||||
"cross-spawn": "^7.0.6",
|
||||
"cross-spawn": "^7.0.2",
|
||||
"debug": "^4.3.2",
|
||||
"doctrine": "^3.0.0",
|
||||
"escape-string-regexp": "^4.0.0",
|
||||
"eslint-scope": "^8.4.0",
|
||||
"eslint-visitor-keys": "^4.2.1",
|
||||
"espree": "^10.4.0",
|
||||
"esquery": "^1.5.0",
|
||||
"eslint-scope": "^7.2.2",
|
||||
"eslint-visitor-keys": "^3.4.3",
|
||||
"espree": "^9.6.1",
|
||||
"esquery": "^1.4.2",
|
||||
"esutils": "^2.0.2",
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"file-entry-cache": "^8.0.0",
|
||||
"file-entry-cache": "^6.0.1",
|
||||
"find-up": "^5.0.0",
|
||||
"glob-parent": "^6.0.2",
|
||||
"globals": "^13.19.0",
|
||||
"graphemer": "^1.4.0",
|
||||
"ignore": "^5.2.0",
|
||||
"imurmurhash": "^0.1.4",
|
||||
"is-glob": "^4.0.0",
|
||||
"is-path-inside": "^3.0.3",
|
||||
"js-yaml": "^4.1.0",
|
||||
"json-stable-stringify-without-jsonify": "^1.0.1",
|
||||
"levn": "^0.4.1",
|
||||
"lodash.merge": "^4.6.2",
|
||||
"minimatch": "^3.1.2",
|
||||
"natural-compare": "^1.4.0",
|
||||
"optionator": "^0.9.3"
|
||||
"optionator": "^0.9.3",
|
||||
"strip-ansi": "^6.0.1",
|
||||
"text-table": "^0.2.0"
|
||||
},
|
||||
"bin": {
|
||||
"eslint": "bin/eslint.js"
|
||||
},
|
||||
"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"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"jiti": "*"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"jiti": {
|
||||
"optional": true
|
||||
}
|
||||
"url": "https://opencollective.com/eslint"
|
||||
}
|
||||
},
|
||||
"node_modules/eslint-config-next": {
|
||||
|
|
@ -2878,9 +2773,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/eslint-scope": {
|
||||
"version": "8.4.0",
|
||||
"resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz",
|
||||
"integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==",
|
||||
"version": "7.2.2",
|
||||
"resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz",
|
||||
"integrity": "sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"dependencies": {
|
||||
|
|
@ -2888,38 +2783,74 @@
|
|||
"estraverse": "^5.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/eslint"
|
||||
}
|
||||
},
|
||||
"node_modules/eslint-visitor-keys": {
|
||||
"version": "4.2.1",
|
||||
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz",
|
||||
"integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==",
|
||||
"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": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"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": {
|
||||
"version": "10.4.0",
|
||||
"resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz",
|
||||
"integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==",
|
||||
"version": "9.6.1",
|
||||
"resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz",
|
||||
"integrity": "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"dependencies": {
|
||||
"acorn": "^8.15.0",
|
||||
"acorn": "^8.9.0",
|
||||
"acorn-jsx": "^5.3.2",
|
||||
"eslint-visitor-keys": "^4.2.1"
|
||||
"eslint-visitor-keys": "^3.4.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/eslint"
|
||||
|
|
@ -3069,16 +3000,16 @@
|
|||
}
|
||||
},
|
||||
"node_modules/file-entry-cache": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
|
||||
"integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==",
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz",
|
||||
"integrity": "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"flat-cache": "^4.0.0"
|
||||
"flat-cache": "^3.0.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16.0.0"
|
||||
"node": "^10.12.0 || >=12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/file-type": {
|
||||
|
|
@ -3129,17 +3060,18 @@
|
|||
}
|
||||
},
|
||||
"node_modules/flat-cache": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz",
|
||||
"integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==",
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.2.0.tgz",
|
||||
"integrity": "sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"flatted": "^3.2.9",
|
||||
"keyv": "^4.5.4"
|
||||
"keyv": "^4.5.3",
|
||||
"rimraf": "^3.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16"
|
||||
"node": "^10.12.0 || >=12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/flatted": {
|
||||
|
|
@ -3195,6 +3127,13 @@
|
|||
"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": {
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||
|
|
@ -3394,13 +3333,16 @@
|
|||
}
|
||||
},
|
||||
"node_modules/globals": {
|
||||
"version": "14.0.0",
|
||||
"resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz",
|
||||
"integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==",
|
||||
"version": "13.24.0",
|
||||
"resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz",
|
||||
"integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"type-fest": "^0.20.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
"node": ">=8"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
|
|
@ -3463,6 +3405,13 @@
|
|||
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
|
||||
"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": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz",
|
||||
|
|
@ -3620,6 +3569,25 @@
|
|||
"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": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz",
|
||||
|
|
@ -3915,6 +3883,16 @@
|
|||
"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": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz",
|
||||
|
|
@ -4616,6 +4594,19 @@
|
|||
"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": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
|
||||
|
|
@ -4976,6 +4967,16 @@
|
|||
"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": {
|
||||
"version": "0.9.4",
|
||||
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
|
||||
|
|
@ -5067,6 +5068,16 @@
|
|||
"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": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
|
||||
|
|
@ -5131,13 +5142,13 @@
|
|||
"license": "ISC"
|
||||
},
|
||||
"node_modules/picomatch": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
|
||||
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
|
||||
"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": ">=8.6"
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
|
|
@ -5489,6 +5500,19 @@
|
|||
"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": {
|
||||
"version": "1.0.10",
|
||||
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
|
||||
|
|
@ -5585,6 +5609,45 @@
|
|||
"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": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
|
||||
|
|
@ -6315,6 +6378,13 @@
|
|||
"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": {
|
||||
"version": "3.3.1",
|
||||
"resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz",
|
||||
|
|
@ -6355,19 +6425,6 @@
|
|||
"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": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
|
||||
|
|
@ -6450,6 +6507,19 @@
|
|||
"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": {
|
||||
"version": "1.0.3",
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"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": {
|
||||
"version": "2.8.2",
|
||||
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz",
|
||||
|
|
|
|||
|
|
@ -23,9 +23,9 @@
|
|||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"eslint": "^9",
|
||||
"eslint": "^8",
|
||||
"eslint-config-next": "^14.2.0",
|
||||
"tailwindcss": "^3.4.1",
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 277 KiB After Width: | Height: | Size: 386 KiB |
|
|
@ -1,22 +1,7 @@
|
|||
import { openDB, DBSchema } from 'idb';
|
||||
import { Track, Playlist } from '@/types';
|
||||
|
||||
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;
|
||||
tracks: Track[];
|
||||
createdAt: number;
|
||||
cover_url?: string;
|
||||
}
|
||||
export type { Track, Playlist };
|
||||
|
||||
interface MyDB extends DBSchema {
|
||||
playlists: {
|
||||
|
|
|
|||
|
|
@ -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
43
frontend/types/index.ts
Normal 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
1
playlist_final.json
Normal 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
1
playlist_response.json
Normal file
|
|
@ -0,0 +1 @@
|
|||
{"detail":"Playlist not found"}
|
||||
1
playlist_response_v10.json
Normal file
1
playlist_response_v10.json
Normal file
|
|
@ -0,0 +1 @@
|
|||
{"detail":"Playlist not found"}
|
||||
1
playlist_response_v11.json
Normal file
1
playlist_response_v11.json
Normal file
|
|
@ -0,0 +1 @@
|
|||
{"detail":"Playlist not found"}
|
||||
1
playlist_response_v12.json
Normal file
1
playlist_response_v12.json
Normal 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_v2.json
Normal file
1
playlist_response_v2.json
Normal file
|
|
@ -0,0 +1 @@
|
|||
{"detail":"Playlist not found"}
|
||||
1
playlist_response_v3.json
Normal file
1
playlist_response_v3.json
Normal file
|
|
@ -0,0 +1 @@
|
|||
{"detail":"Playlist not found"}
|
||||
1
playlist_response_v4.json
Normal file
1
playlist_response_v4.json
Normal file
|
|
@ -0,0 +1 @@
|
|||
{"detail":"Playlist not found"}
|
||||
1
playlist_response_v5.json
Normal file
1
playlist_response_v5.json
Normal file
|
|
@ -0,0 +1 @@
|
|||
{"detail":"Playlist not found"}
|
||||
1
playlist_response_v6.json
Normal file
1
playlist_response_v6.json
Normal file
|
|
@ -0,0 +1 @@
|
|||
{"detail":"Playlist not found"}
|
||||
1
playlist_response_v7.json
Normal file
1
playlist_response_v7.json
Normal file
|
|
@ -0,0 +1 @@
|
|||
{"detail":"Playlist not found"}
|
||||
1
playlist_response_v8.json
Normal file
1
playlist_response_v8.json
Normal file
|
|
@ -0,0 +1 @@
|
|||
{"detail":"Playlist not found"}
|
||||
1
playlist_response_v9.json
Normal file
1
playlist_response_v9.json
Normal file
|
|
@ -0,0 +1 @@
|
|||
{"detail":"Playlist not found"}
|
||||
1
rec_response_v2.json
Normal file
1
rec_response_v2.json
Normal file
File diff suppressed because one or more lines are too long
1
response.json
Normal file
1
response.json
Normal file
File diff suppressed because one or more lines are too long
1
response_v2.json
Normal file
1
response_v2.json
Normal file
File diff suppressed because one or more lines are too long
1
response_v3.json
Normal file
1
response_v3.json
Normal file
File diff suppressed because one or more lines are too long
Loading…
Reference in a new issue