- {isActive && isPlaying ? (
+
+ {playbackAccess.isBlocked ? (
+
+ ) : isActive && isPlaying ? (
) : (
-
+
)}
@@ -733,10 +788,12 @@ export default function TrackCard({
{/* Waveform Area - Hidden on mobile for professional look */}
@@ -814,10 +871,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..d3f455c3
--- /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 = "EG";
+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 `