From 7e19936119fe73c2e2c8b061f7e3766ffc9aa64f Mon Sep 17 00:00:00 2001 From: Shahd-Yehya Date: Sat, 2 May 2026 14:56:48 +0300 Subject: [PATCH 1/2] Finished PlaybackAccess, fixing comments in trackcard --- src/components/Player.tsx | 156 ++++++++++++++++++ src/components/feed/FeedItemCard.tsx | 2 +- src/components/player/PlayerControls.tsx | 28 +++- src/components/player/ProgressBar.tsx | 6 +- src/components/player/StickyPlayer.tsx | 142 +--------------- src/components/playlist/PlaylistComponent.tsx | 27 ++- src/components/track/TrackCard.tsx | 89 ++++++++-- src/config/playbackRegion.ts | 41 +++++ src/hooks/usePlaybackAccess.ts | 33 ++++ src/services/api/axiosInstance.ts | 5 + src/services/audioService.ts | 9 +- src/services/track.service.ts | 33 +++- src/stores/player.store.ts | 73 +++++--- src/types/track.ts | 10 ++ src/utils/playbackAccess.ts | 122 ++++++++++++++ 15 files changed, 585 insertions(+), 191 deletions(-) create mode 100644 src/components/Player.tsx create mode 100644 src/config/playbackRegion.ts create mode 100644 src/hooks/usePlaybackAccess.ts create mode 100644 src/utils/playbackAccess.ts diff --git a/src/components/Player.tsx b/src/components/Player.tsx new file mode 100644 index 00000000..4239f13c --- /dev/null +++ b/src/components/Player.tsx @@ -0,0 +1,156 @@ +/** + * Main sticky player shell — playback URL selection is done in `track.service` / + * `utils/playbackAccess` (stream vs preview vs blocked). This component surfaces + * access state in the chrome and disables transport controls when there is + * nothing to play. + */ + +import { seekAudio } from "../services/audioService"; +import { useState } from "react"; +import { Link } from "react-router-dom"; +import { usePlayerStore } from "../stores/player.store"; +import { useLikesStore } from "../stores/likes.store"; +import { useAuthStore } from "../stores/auth.store"; +import { usePlaybackAccess } from "../hooks/usePlaybackAccess"; +import PlayerControls from "./player/PlayerControls"; +import ProgressBar from "./player/ProgressBar"; +import VolumeSlider from "./player/VolumeSlider"; +import QueuePanel from "./player/QueuePanel"; +import { FaHeart, FaUserPlus, FaUserCheck } from "react-icons/fa"; +import { MdQueueMusic } from "react-icons/md"; +import FollowButton from "./UI/FollowButton"; + +export default function Player() { + const { + currentTrack, + currentTime, + duration, + volume, + isMuted, + setVolume, + toggleMute, + } = usePlayerStore(); + + const { isTrackLiked, toggleTrack } = useLikesStore(); + const { user } = useAuthStore(); + const access = usePlaybackAccess(currentTrack ?? undefined); + + const [queueOpen, setQueueOpen] = useState(false); + + const isLiked = currentTrack ? isTrackLiked(currentTrack.id) : false; + const isFollowing = currentTrack + ? (user?.following_ids ?? []).includes(currentTrack.artistId || currentTrack.artistUsername) + : false; + + if (!currentTrack) return null; + + const playbackDisabled = access.isBlocked || !access.effectiveAudioUrl; + + return ( +
+
+ + +
+ {access.isBlocked && ( +

+ Playback unavailable in your region or for this track. +

+ )} + {access.isPreview && !access.isBlocked && ( +

+ Preview only — full track not available here. +

+ )} + +
+ + + +
+ {currentTrack.coverUrl && ( + + {currentTrack.title} + + )} +
+ + {currentTrack.title} + + + {currentTrack.artistName} + +
+
+ + + +
+ + {isFollowing ? : } + +
+ + + + {queueOpen && setQueueOpen(false)} />} +
+
+ ); +} diff --git a/src/components/feed/FeedItemCard.tsx b/src/components/feed/FeedItemCard.tsx index b3b43bc5..423dc71d 100644 --- a/src/components/feed/FeedItemCard.tsx +++ b/src/components/feed/FeedItemCard.tsx @@ -28,7 +28,7 @@ const FeedItemCard = ({ item }: FeedItemCardProps) => { const cardBody = item.content_type === "track" ? ( - + ) : ( ); diff --git a/src/components/player/PlayerControls.tsx b/src/components/player/PlayerControls.tsx index 79514c5e..61854bdc 100644 --- a/src/components/player/PlayerControls.tsx +++ b/src/components/player/PlayerControls.tsx @@ -9,7 +9,12 @@ import { import { MdRepeat, MdRepeatOne } from "react-icons/md"; import { toast } from "sonner"; -export default function PlayerControls() { +interface PlayerControlsProps { + /** When true, play/pause has no audio source (blocked region or missing URLs). */ + playbackDisabled?: boolean; +} + +export default function PlayerControls({ playbackDisabled = false }: PlayerControlsProps) { const { isPlaying, isShuffle, @@ -54,12 +59,23 @@ export default function PlayerControls() { {/* Play / Pause */} - - {/* 5. Follow */} -
- - {isFollowing ? : } - -
- - {/* 6. Queue */} - - - {/* Queue panel */} - {queueOpen && setQueueOpen(false)} />} - - - - - - ); -} \ No newline at end of file +/** + * @deprecated Import from `components/Player` — kept so existing layouts keep working. + */ +export { default } from "../Player"; diff --git a/src/components/playlist/PlaylistComponent.tsx b/src/components/playlist/PlaylistComponent.tsx index 7ee4e3ea..581cc927 100644 --- a/src/components/playlist/PlaylistComponent.tsx +++ b/src/components/playlist/PlaylistComponent.tsx @@ -333,6 +333,9 @@ export default function PlaylistComponent({ const instanceId = useMemo(() => uniqueId ?? `playlist-instance-${Math.random().toString(36).substr(2, 9)}`, [uniqueId]); + // Defensive: check for valid playlistSlug + const hasValidSlug = !!playlist.playlistSlug && playlist.playlistSlug !== "undefined" && playlist.playlistSlug !== ""; + const isOwner = !!user && user.username === playlist.creatorUsername; const firstTrack = playlist.tracks[0] ?? null; @@ -576,13 +579,23 @@ export default function PlaylistComponent({ {/* Playlist title */} - - {playlist.title} - + {hasValidSlug ? ( + + {playlist.title} + + ) : ( + + {playlist.title} + + )} {/* Timestamp + track count */} diff --git a/src/components/track/TrackCard.tsx b/src/components/track/TrackCard.tsx index cca37cab..8d7cc5b5 100644 --- a/src/components/track/TrackCard.tsx +++ b/src/components/track/TrackCard.tsx @@ -5,7 +5,7 @@ import { useEffect, useRef, useState, useCallback } from "react"; import { Link } from "react-router-dom"; import WaveSurfer from "wavesurfer.js"; -import { FaPlay, FaPause, FaHeart } from "react-icons/fa"; +import { FaPlay, FaPause, FaHeart, FaLock } from "react-icons/fa"; import { BiRepost } from "react-icons/bi"; import { LuCopy, LuPencil, LuTrash2 } from "react-icons/lu"; import { HiDotsHorizontal } from "react-icons/hi"; @@ -39,6 +39,8 @@ import EditTrackModal from "./EditTrackModal"; import DeleteTrackModal from "./DeleteTrackModal"; import ReplaceAudioModal from "./ReplaceAudioModal"; import AddToPlaylistModal from "@/components/playlist/AddToPlaylistModal"; +import { usePlaybackAccess } from "../../hooks/usePlaybackAccess"; +import type { PlaybackAccessState } from "../../utils/playbackAccess"; // helpers @@ -175,6 +177,10 @@ interface CardWaveformProps { onWaveformClick: (ratio: number) => void; comments: Comment[]; pendingRatio: number | null; + /** From `getPlaybackState` — blocks waveform seek/load when not playable/preview. */ + playbackState: PlaybackAccessState; + /** When true (e.g. feed), waveform does not open the comment UI. */ + disableComments?: boolean; } function CardWaveform({ @@ -183,6 +189,8 @@ function CardWaveform({ onWaveformClick, comments, pendingRatio, + playbackState, + disableComments = false, }: CardWaveformProps) { const containerRef = useRef(null); const wsRef = useRef(null); @@ -251,6 +259,9 @@ function CardWaveform({ setWaveformDuration(dur); }); } else { + const canInteract = playbackState !== "blocked"; + const remoteUrl = + canInteract && !hasPeaks && track.audioUrl ? track.audioUrl : undefined; ws = WaveSurfer.create({ container: containerRef.current!, waveColor: g, @@ -259,13 +270,14 @@ function CardWaveform({ barGap: 0.5, barRadius: 2, height: 80, - interact: true, // Enable interaction even if not active so user can click waveform to comment + interact: canInteract, peaks: hasPeaks ? [peaks] : undefined, duration: durationFallback > 0 ? durationFallback : undefined, - url: !hasPeaks ? track.audioUrl : undefined, + url: remoteUrl, }); ws.on("interaction", (newTime: number) => { + if (!canInteract) return; const dur = durationFallback; onWaveformClick?.(dur > 0 ? newTime / dur : 0); }); @@ -289,7 +301,7 @@ function CardWaveform({ } catch { /* ok */ } wsRef.current = null; }; - }, [isActive, track.id, track.audioUrl]); + }, [isActive, track.id, track.audioUrl, playbackState]); const durSec = waveformDuration || parseDur(track.duration); @@ -378,6 +390,24 @@ function CardWaveform({ /> )} + + {/* Geo-blocked tracks use WaveSurfer with interact:false (no playback), so clicks never fire. + This layer restores "click waveform to comment" using the same ratio as WaveSurfer would. */} + {playbackState === "blocked" && !disableComments && ( + @@ -732,10 +787,12 @@ export default function TrackCard({ {/* Waveform Area - Hidden on mobile for professional look */}
@@ -813,10 +870,20 @@ export default function TrackCard({ } tooltip="Copy Link" onClick={onCopyLink} data-test="track-card-btn-copy" /> } - tooltip={addedToQueue ? "Added!" : "Add to Next up"} + tooltip={ + playbackAccess.isBlocked + ? "Playback unavailable for this track" + : addedToQueue + ? "Added!" + : "Add to Next up" + } active={addedToQueue} data-test="track-card-btn-queue" onClick={() => { + if (playbackAccess.isBlocked) { + toast.message("This track cannot be queued for playback here."); + return; + } addNextInQueue(track); setAddedToQueue(true); toast.success(`"${track.title}" added to Next up`, { diff --git a/src/config/playbackRegion.ts b/src/config/playbackRegion.ts new file mode 100644 index 00000000..66772087 --- /dev/null +++ b/src/config/playbackRegion.ts @@ -0,0 +1,41 @@ +/** + * Playback region configuration + * --------------------------- + * The backend uses `X-Country-Code` (ISO 3166-1 alpha-2) to mask or allow + * `stream_url`, `preview_url`, and related fields. This module centralizes the + * value so you can later swap `getPlaybackCountryCode()` to read from the + * logged-in user, GeoIP, CDN headers, or subscription tier without touching + * every service call. + */ + +const DEFAULT_PLAYBACK_COUNTRY_CODE = "US"; +const PLAYBACK_COUNTRY_OVERRIDE_KEY = "playback_country_code"; + +let playbackCountryCode = DEFAULT_PLAYBACK_COUNTRY_CODE; + +/** Current ISO country code sent on track and player API requests. */ +export function getPlaybackCountryCode(): string { + // Dev/testing override so QA can switch region without rebuild/redeploy. + const override = + typeof window !== "undefined" + ? window.localStorage.getItem(PLAYBACK_COUNTRY_OVERRIDE_KEY) + : null; + if (override && /^[A-Za-z]{2}$/.test(override.trim())) { + return override.trim().toUpperCase(); + } + return playbackCountryCode; +} + +/** + * Override the playback region (e.g. after loading user profile). + * Accepts only two-letter codes; invalid input is ignored. + */ +export function setPlaybackCountryCode(code: string): void { + if (typeof code === "string" && /^[A-Za-z]{2}$/.test(code.trim())) { + const normalized = code.trim().toUpperCase(); + playbackCountryCode = normalized; + if (typeof window !== "undefined") { + window.localStorage.setItem(PLAYBACK_COUNTRY_OVERRIDE_KEY, normalized); + } + } +} diff --git a/src/hooks/usePlaybackAccess.ts b/src/hooks/usePlaybackAccess.ts new file mode 100644 index 00000000..4ce5dfcf --- /dev/null +++ b/src/hooks/usePlaybackAccess.ts @@ -0,0 +1,33 @@ +import { useMemo } from "react"; +import type { Track } from "../types/track"; +import { + getPlaybackState, + resolvePlaybackAudioUrl, + type PlaybackAccessState, +} from "../utils/playbackAccess"; + +export type { PlaybackAccessState }; + +/** + * React hook for playback access on a single track. + * Prefer this in components; use `getPlaybackState` / `resolvePlaybackAudioUrl` + * in non-React code (stores, services). + */ +export function usePlaybackAccess(track: Track | null | undefined) { + return useMemo(() => { + // The access utilities accept raw API (snake_case) and normalized Track objects. + // We cast here to avoid requiring an index signature on the `Track` interface. + const trackLike = track as unknown; + const state = getPlaybackState(trackLike as any); + const effectiveAudioUrl = resolvePlaybackAudioUrl(trackLike as any); + return { + state, + effectiveAudioUrl, + isPlayable: state === "playable", + isPreview: state === "preview", + isBlocked: state === "blocked", + }; + }, [track]); +} + +export { getPlaybackState, resolvePlaybackAudioUrl } from "../utils/playbackAccess"; diff --git a/src/services/api/axiosInstance.ts b/src/services/api/axiosInstance.ts index e7bd0cc8..f31c3792 100644 --- a/src/services/api/axiosInstance.ts +++ b/src/services/api/axiosInstance.ts @@ -1,4 +1,5 @@ import axios from 'axios'; +import { getPlaybackCountryCode } from '../../config/playbackRegion'; const REFRESH_FAILURE_COOLDOWN_MS = 30_000; @@ -27,6 +28,10 @@ axiosInstance.interceptors.request.use((config) => { if (token) { config.headers.Authorization = `Bearer ${token}`; } + // Geo / tier-aware track and playback responses depend on this header. + if (config.url && !config.url.includes('/auth/')) { + config.headers['X-Country-Code'] = getPlaybackCountryCode(); + } return config; }); diff --git a/src/services/audioService.ts b/src/services/audioService.ts index cf9a9a67..02e90a47 100644 --- a/src/services/audioService.ts +++ b/src/services/audioService.ts @@ -105,9 +105,16 @@ usePlayerStore.subscribe((state, prev) => { loadedAudioTrackId = state.currentTrack.id; const targetTime = state.currentTime; + const url = (state.currentTrack.audioUrl || "").trim(); audio.pause(); - audio.src = state.currentTrack.audioUrl; + // Blocked or missing URLs: keep element empty so we never request a bad src. + if (!url) { + audio.removeAttribute("src"); + return; + } + + audio.src = url; audio.load(); // Seek to the preserved position once metadata is ready. diff --git a/src/services/track.service.ts b/src/services/track.service.ts index 84c7884d..88537f9a 100644 --- a/src/services/track.service.ts +++ b/src/services/track.service.ts @@ -1,7 +1,13 @@ +/** + * Track API layer — all calls use the shared axios instance, which attaches + * `X-Country-Code` (see `config/playbackRegion.ts`) so stream/preview URLs match + * the listener’s region. + */ import axiosInstance from "./api/axiosInstance"; import type { Track } from "../types/track"; import type { Comment } from "../types/comment"; import { formatPostedAt } from "./Time"; +import { resolvePlaybackAudioUrl } from "../utils/playbackAccess"; /** * Normalizes a raw API track object into the frontend Track type. @@ -25,6 +31,7 @@ function normalizeTrack(raw: any): Track { waveformData: [], audioUrl: "", isPrivate: false, + enableAppPlayback: true, }; } @@ -38,12 +45,13 @@ function normalizeTrack(raw: any): Track { duration = durationRaw; } - return { + const streamUrlRaw = raw.stream_url || raw.audio_url || raw.audioUrl || null; + const previewUrlRaw = raw.preview_url ?? raw.previewUrl ?? null; + + const merged = { ...raw, id: raw.id, title: raw.title ?? "", - // FIX: map all possible audio URL field names so the player always has a URL - audioUrl: raw.stream_url || raw.audio_url || raw.audioUrl || "", coverUrl: raw.cover_image || raw.cover_url || raw.coverUrl || "", artistName: raw.artist_name || raw.user?.display_name || raw.artistName || "", @@ -54,9 +62,7 @@ function normalizeTrack(raw: any): Track { raw.username || raw.owner_username || "", - // FIX: map slug field from API, fallback to id to ensure valid navigation URL trackSlug: raw.slug || raw.track_slug || raw.trackSlug || raw.id || "", - // FIX: format ISO timestamp into human-readable string postedAt: formatPostedAt(raw.created_at || raw.postedAt || ""), playCount: raw.play_count ?? raw.playCount ?? 0, likeCount: raw.like_count ?? raw.likeCount ?? 0, @@ -69,6 +75,23 @@ function normalizeTrack(raw: any): Track { raw.is_reposted_by_me ?? raw.is_reposted ?? raw.isReposted ?? false, artistId: raw.user_id || raw.artistId || "", duration, + streamUrl: + typeof streamUrlRaw === "string" && streamUrlRaw.trim() + ? streamUrlRaw.trim() + : undefined, + previewUrl: + typeof previewUrlRaw === "string" && previewUrlRaw.trim() + ? previewUrlRaw.trim() + : undefined, + isGeoBlocked: raw.is_geo_blocked === true, + playbackRestrictionReason: raw.playback_restriction_reason ?? null, + enableAppPlayback: raw.enable_app_playback !== false, + }; + + return { + ...merged, + // `audioUrl` is the actual source for `