Save current app updates
This commit is contained in:
@@ -408,9 +408,7 @@ const styles = StyleSheet.create({
|
|||||||
flex: 1,
|
flex: 1,
|
||||||
},
|
},
|
||||||
scrollContent: {
|
scrollContent: {
|
||||||
flexGrow: 1,
|
paddingTop: 48,
|
||||||
justifyContent: 'space-between',
|
|
||||||
paddingTop: 60,
|
|
||||||
},
|
},
|
||||||
headerContainer: {
|
headerContainer: {
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
@@ -460,12 +458,8 @@ const styles = StyleSheet.create({
|
|||||||
width: '100%',
|
width: '100%',
|
||||||
borderTopLeftRadius: 32,
|
borderTopLeftRadius: 32,
|
||||||
borderTopRightRadius: 32,
|
borderTopRightRadius: 32,
|
||||||
borderBottomLeftRadius: 0,
|
|
||||||
borderBottomRightRadius: 0,
|
|
||||||
padding: 24,
|
padding: 24,
|
||||||
paddingBottom: Platform.OS === 'ios' ? 40 : 24,
|
paddingBottom: Platform.OS === 'ios' ? 40 : 32,
|
||||||
flexGrow: 1,
|
|
||||||
justifyContent: 'flex-start',
|
|
||||||
shadowColor: '#000',
|
shadowColor: '#000',
|
||||||
shadowOffset: { width: 0, height: -4 },
|
shadowOffset: { width: 0, height: -4 },
|
||||||
shadowOpacity: 0.1,
|
shadowOpacity: 0.1,
|
||||||
|
|||||||
@@ -6,6 +6,85 @@ import { colors } from '../../utils/colors';
|
|||||||
import { supabase } from '../../services/supabase';
|
import { supabase } from '../../services/supabase';
|
||||||
import { getSpotifyAccessToken, refreshSpotifyToken, clearSpotifyTokens } from '../../auth/spotifyToken';
|
import { getSpotifyAccessToken, refreshSpotifyToken, clearSpotifyTokens } from '../../auth/spotifyToken';
|
||||||
import { OLLAMA_API_URL } from '../../services/ollama';
|
import { OLLAMA_API_URL } from '../../services/ollama';
|
||||||
|
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||||
|
|
||||||
|
// ── Trip Stop types & route helpers ──────────────────────────────────────────
|
||||||
|
|
||||||
|
interface TripStop {
|
||||||
|
name: string;
|
||||||
|
category: string;
|
||||||
|
address: string;
|
||||||
|
latitude: number;
|
||||||
|
longitude: number;
|
||||||
|
place_id: string;
|
||||||
|
index: number;
|
||||||
|
}
|
||||||
|
type SpotifyArtist = {
|
||||||
|
id?: string | null;
|
||||||
|
name?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type SpotifySearchTrack = {
|
||||||
|
id?: string | null;
|
||||||
|
uri?: string | null;
|
||||||
|
duration_ms?: number | null;
|
||||||
|
is_local?: boolean | null;
|
||||||
|
is_playable?: boolean | null;
|
||||||
|
artists?: SpotifyArtist[] | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type SelectedSpotifyTrack = {
|
||||||
|
id: string;
|
||||||
|
uri: string;
|
||||||
|
duration_ms: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Maps trip duration (seconds) to the target number of stops. */
|
||||||
|
function getStopCount(durationSeconds: number): number {
|
||||||
|
const minutes = durationSeconds / 60;
|
||||||
|
if (minutes < 60) return 0;
|
||||||
|
if (minutes < 180) return 1;
|
||||||
|
if (minutes < 360) return 3;
|
||||||
|
if (minutes < 720) return 5;
|
||||||
|
if (minutes < 1200) return 7;
|
||||||
|
return 8;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns N {lat, lng} points distributed at equal time intervals along the
|
||||||
|
* route, using the step list from the Google Directions API leg.
|
||||||
|
*/
|
||||||
|
function getRouteWaypoints(
|
||||||
|
steps: any[],
|
||||||
|
totalDurationSeconds: number,
|
||||||
|
count: number
|
||||||
|
): Array<{ lat: number; lng: number }> {
|
||||||
|
if (count === 0 || steps.length === 0) return [];
|
||||||
|
const waypoints: Array<{ lat: number; lng: number }> = [];
|
||||||
|
let cumulative = 0;
|
||||||
|
let nextTarget = 1;
|
||||||
|
for (const step of steps) {
|
||||||
|
cumulative += (step.duration?.value ?? 0);
|
||||||
|
while (nextTarget <= count) {
|
||||||
|
const target = (nextTarget / (count + 1)) * totalDurationSeconds;
|
||||||
|
if (cumulative >= target) {
|
||||||
|
waypoints.push({ lat: step.end_location.lat, lng: step.end_location.lng });
|
||||||
|
nextTarget++;
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (nextTarget > count) break;
|
||||||
|
}
|
||||||
|
// Pad with last step coordinates if the route ran short
|
||||||
|
const last = steps[steps.length - 1];
|
||||||
|
while (waypoints.length < count && last) {
|
||||||
|
waypoints.push({ lat: last.end_location?.lat ?? 0, lng: last.end_location?.lng ?? 0 });
|
||||||
|
}
|
||||||
|
return waypoints;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
export default function NewTripScreen({ navigation }) {
|
export default function NewTripScreen({ navigation }) {
|
||||||
@@ -40,7 +119,7 @@ export default function NewTripScreen({ navigation }) {
|
|||||||
const finalDistance = leg.distance.text;
|
const finalDistance = leg.distance.text;
|
||||||
const finalDuration = leg.duration.text;
|
const finalDuration = leg.duration.text;
|
||||||
const tripDurationMs = leg.duration.value * 1000;
|
const tripDurationMs = leg.duration.value * 1000;
|
||||||
|
|
||||||
setDistance(finalDistance);
|
setDistance(finalDistance);
|
||||||
setDuration(finalDuration);
|
setDuration(finalDuration);
|
||||||
|
|
||||||
@@ -59,7 +138,7 @@ export default function NewTripScreen({ navigation }) {
|
|||||||
console.log(`PLAYLIST_API_STATUS [${label}]:`, res.status);
|
console.log(`PLAYLIST_API_STATUS [${label}]:`, res.status);
|
||||||
console.log(`PLAYLIST_API_CONTENT_TYPE [${label}]:`, res.headers.get("content-type"));
|
console.log(`PLAYLIST_API_CONTENT_TYPE [${label}]:`, res.headers.get("content-type"));
|
||||||
console.log(`PLAYLIST_API_RAW_RESPONSE [${label}]:`, rawText.substring(0, 300) + (rawText.length > 300 ? "..." : ""));
|
console.log(`PLAYLIST_API_RAW_RESPONSE [${label}]:`, rawText.substring(0, 300) + (rawText.length > 300 ? "..." : ""));
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
throw new Error(`Spotify API returned status ${res.status} for [${label}]: ${rawText.substring(0, 150)}`);
|
throw new Error(`Spotify API returned status ${res.status} for [${label}]: ${rawText.substring(0, 150)}`);
|
||||||
}
|
}
|
||||||
@@ -81,39 +160,39 @@ export default function NewTripScreen({ navigation }) {
|
|||||||
console.log("SPOTIFY_ACCESS_TOKEN_EXISTS:", !!providerToken);
|
console.log("SPOTIFY_ACCESS_TOKEN_EXISTS:", !!providerToken);
|
||||||
|
|
||||||
if (providerToken) {
|
if (providerToken) {
|
||||||
// Validate token via GET /v1/me (free endpoint, no Premium required)
|
// Validate token via GET /v1/me (free endpoint, no Premium required)
|
||||||
let meRes = await fetch('https://api.spotify.com/v1/me', {
|
let meRes = await fetch('https://api.spotify.com/v1/me', {
|
||||||
headers: { Authorization: `Bearer ${providerToken}` }
|
headers: { Authorization: `Bearer ${providerToken}` }
|
||||||
});
|
});
|
||||||
|
|
||||||
if (meRes.status === 401) {
|
if (meRes.status === 401) {
|
||||||
console.log("Spotify token is invalid/expired (401), attempting to refresh...");
|
console.log("Spotify token is invalid/expired (401), attempting to refresh...");
|
||||||
const newToken = await refreshSpotifyToken();
|
const newToken = await refreshSpotifyToken();
|
||||||
if (newToken) {
|
if (newToken) {
|
||||||
providerToken = newToken;
|
providerToken = newToken;
|
||||||
console.log("Spotify token refreshed successfully!");
|
console.log("Spotify token refreshed successfully!");
|
||||||
} else {
|
} else {
|
||||||
console.log("Failed to refresh Spotify token.");
|
console.log("Failed to refresh Spotify token.");
|
||||||
providerToken = null;
|
providerToken = null;
|
||||||
}
|
}
|
||||||
} else if (!meRes.ok) {
|
} else if (!meRes.ok) {
|
||||||
const meErr = await meRes.text();
|
const meErr = await meRes.text();
|
||||||
console.warn("Spotify GET /v1/me failed:", meRes.status, meErr);
|
console.warn("Spotify GET /v1/me failed:", meRes.status, meErr);
|
||||||
providerToken = null;
|
providerToken = null;
|
||||||
} else {
|
} else {
|
||||||
console.log("Spotify token valid (GET /v1/me returned 200 OK).");
|
console.log("Spotify token valid (GET /v1/me returned 200 OK).");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!providerToken) {
|
if (!providerToken) {
|
||||||
console.log("Spotify token missing or expired, skipping playlist generation.");
|
console.log("Spotify token missing or expired, skipping playlist generation.");
|
||||||
playlistCreationFailed = true;
|
playlistCreationFailed = true;
|
||||||
playlistFailureReason = 'token';
|
playlistFailureReason = 'token';
|
||||||
Alert.alert('Spotify Desligado', 'O token do Spotify expirou ou está em falta. Por favor reconecte o Spotify no Perfil.');
|
Alert.alert('Spotify Desligado', 'O token do Spotify expirou ou está em falta. Por favor reconecte o Spotify no Perfil.');
|
||||||
} else {
|
} else {
|
||||||
// B. Fetch Spotify User ID (reuse /v1/me — already validated above)
|
// B. Fetch Spotify User ID (reuse /v1/me — already validated above)
|
||||||
const spotifyUserRes = await fetch('https://api.spotify.com/v1/me', {
|
const spotifyUserRes = await fetch('https://api.spotify.com/v1/me', {
|
||||||
headers: {
|
headers: {
|
||||||
'Authorization': `Bearer ${providerToken}`,
|
'Authorization': `Bearer ${providerToken}`,
|
||||||
'Content-Type': 'application/json'
|
'Content-Type': 'application/json'
|
||||||
}
|
}
|
||||||
@@ -124,173 +203,408 @@ export default function NewTripScreen({ navigation }) {
|
|||||||
console.log("SPOTIFY_USER_ID_EXISTS:", !!spotifyUserId);
|
console.log("SPOTIFY_USER_ID_EXISTS:", !!spotifyUserId);
|
||||||
if (!spotifyUserId) throw new Error('Could not fetch Spotify User ID from /v1/me');
|
if (!spotifyUserId) throw new Error('Could not fetch Spotify User ID from /v1/me');
|
||||||
|
|
||||||
// C. Call Ollama server
|
// C. Build varied music queries using AI + favorite genre
|
||||||
const ollamaPrompt = `I am taking a roadtrip from ${origin} to ${destination}. The trip is called "${tripName}" and takes about ${duration}. Reply ONLY with a JSON array of up to 10 Spotify search queries (e.g. genres, moods, or themes) that fit this journey. Example: ["portuguese pop", "italian road trip", "european indie", "summer travel songs"]. No other text.`;
|
function shuffleArray<T>(array: T[]): T[] {
|
||||||
const ollamaRes = await fetch(`${OLLAMA_API_URL}/api/chat`, {
|
const copy = [...array];
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
for (let i = copy.length - 1; i > 0; i--) {
|
||||||
body: JSON.stringify({
|
const j = Math.floor(Math.random() * (i + 1));
|
||||||
model: "qwen3-coder:30b",
|
[copy[i], copy[j]] = [copy[j], copy[i]];
|
||||||
messages: [{ "role": "user", "content": ollamaPrompt }],
|
}
|
||||||
stream: false
|
|
||||||
})
|
return copy;
|
||||||
});
|
|
||||||
|
|
||||||
let searchQueries: string[] = ["pop hits", "road trip songs", "top hits Portugal", "summer hits", "travel songs"]; // Fallback
|
|
||||||
try {
|
|
||||||
const ollamaData = await safeParseJson(ollamaRes, 'Ollama');
|
|
||||||
let rawAiText = ollamaData?.message?.content || "";
|
|
||||||
|
|
||||||
// Clean AI text
|
|
||||||
rawAiText = rawAiText.replace(/```json/g, '').replace(/```/g, '').trim();
|
|
||||||
|
|
||||||
if (rawAiText.length > 0 && rawAiText.startsWith("[")) {
|
|
||||||
const parsed = JSON.parse(rawAiText);
|
|
||||||
if (Array.isArray(parsed) && parsed.length > 0) {
|
|
||||||
const aiQueries = parsed.map(String).slice(0, 10);
|
|
||||||
searchQueries = [...aiQueries, ...searchQueries];
|
|
||||||
} else {
|
|
||||||
console.log("Ollama returned empty array, using fallbacks");
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
console.log("AI returned plain text/error, using fallback queries:", rawAiText);
|
|
||||||
}
|
|
||||||
} catch (aiError) {
|
|
||||||
console.log("AI parsing failed, using fallback queries.", aiError);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function cleanSearchQuery(query: string): string {
|
||||||
|
return query
|
||||||
|
.replace(/["[\]]/g, "")
|
||||||
|
.replace(/\s+/g, " ")
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRandomSpotifyOffset(): number {
|
||||||
|
const offsets = [0, 10, 20, 30, 40, 50];
|
||||||
|
return offsets[Math.floor(Math.random() * offsets.length)];
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMainArtistKey(track: any): string {
|
||||||
|
return (
|
||||||
|
track?.artists?.[0]?.id ||
|
||||||
|
track?.artists?.[0]?.name ||
|
||||||
|
"unknown_artist"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readFavoriteGenreForPlaylist(): Promise<string> {
|
||||||
|
try {
|
||||||
|
const {
|
||||||
|
data: { session },
|
||||||
|
} = await supabase.auth.getSession();
|
||||||
|
|
||||||
|
const appUserId = session?.user?.id ?? null;
|
||||||
|
|
||||||
|
const possibleKeys = [
|
||||||
|
appUserId ? `favoriteGenre:${appUserId}` : "",
|
||||||
|
appUserId ? `userFavoriteGenre:${appUserId}` : "",
|
||||||
|
appUserId ? `@roadtripdj:favoriteGenre:${appUserId}` : "",
|
||||||
|
"favoriteGenre",
|
||||||
|
"userFavoriteGenre",
|
||||||
|
"@roadtripdj:favoriteGenre",
|
||||||
|
].filter(Boolean);
|
||||||
|
|
||||||
|
for (const key of possibleKeys) {
|
||||||
|
const value = await AsyncStorage.getItem(key);
|
||||||
|
|
||||||
|
if (value && value.trim().length > 0) {
|
||||||
|
return value.trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (appUserId) {
|
||||||
|
try {
|
||||||
|
const { data } = await supabase
|
||||||
|
.from("profiles")
|
||||||
|
.select("favorite_genre")
|
||||||
|
.eq("id", appUserId)
|
||||||
|
.maybeSingle();
|
||||||
|
|
||||||
|
const genre = (data as any)?.favorite_genre;
|
||||||
|
|
||||||
|
if (genre && String(genre).trim().length > 0) {
|
||||||
|
return String(genre).trim();
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Ignore profile lookup errors. Favorite genre is optional.
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { data } = await supabase
|
||||||
|
.from("profiles")
|
||||||
|
.select("favoriteGenre")
|
||||||
|
.eq("id", appUserId)
|
||||||
|
.maybeSingle();
|
||||||
|
|
||||||
|
const genre = (data as any)?.favoriteGenre;
|
||||||
|
|
||||||
|
if (genre && String(genre).trim().length > 0) {
|
||||||
|
return String(genre).trim();
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Ignore profile lookup errors. Favorite genre is optional.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("Failed to read favorite genre:", error);
|
||||||
|
}
|
||||||
|
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
const favoriteGenre = await readFavoriteGenreForPlaylist();
|
||||||
|
|
||||||
|
console.log("PLAYLIST_FAVORITE_GENRE_USED:", favoriteGenre || "none");
|
||||||
|
|
||||||
|
const ollamaPrompt = `I am taking a roadtrip from ${origin} to ${destination}.
|
||||||
|
The trip is called "${tripName}" and takes about ${duration}.
|
||||||
|
The user's favorite music genre is: "${favoriteGenre || "not set"}".
|
||||||
|
|
||||||
|
Reply ONLY with a JSON array of up to 10 Spotify search queries.
|
||||||
|
The queries should be varied and specific to this trip.
|
||||||
|
If a favorite genre is set, strongly include it in the search ideas.
|
||||||
|
Do NOT return song names.
|
||||||
|
Do NOT return explanations.
|
||||||
|
Example: ["rock road trip", "rock hits", "porto travel songs", "portuguese indie"].`;
|
||||||
|
|
||||||
|
let aiQueries: string[] = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const ollamaRes = await fetch(`${OLLAMA_API_URL}/api/chat`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
model: "qwen3-coder:30b",
|
||||||
|
messages: [{ role: "user", content: ollamaPrompt }],
|
||||||
|
stream: false,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const ollamaData = await safeParseJson(ollamaRes, "Ollama");
|
||||||
|
let rawAiText = ollamaData?.message?.content || "";
|
||||||
|
|
||||||
|
rawAiText = rawAiText
|
||||||
|
.replace(/```json/g, "")
|
||||||
|
.replace(/```/g, "")
|
||||||
|
.trim();
|
||||||
|
|
||||||
|
if (rawAiText.length > 0 && rawAiText.startsWith("[")) {
|
||||||
|
const parsed = JSON.parse(rawAiText);
|
||||||
|
|
||||||
|
if (Array.isArray(parsed)) {
|
||||||
|
aiQueries = parsed
|
||||||
|
.map(String)
|
||||||
|
.map(cleanSearchQuery)
|
||||||
|
.filter(Boolean)
|
||||||
|
.slice(0, 10);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (aiError) {
|
||||||
|
console.log("AI parsing failed, using fallback queries.", aiError);
|
||||||
|
}
|
||||||
|
|
||||||
|
const favoriteGenreQueries = favoriteGenre
|
||||||
|
? [
|
||||||
|
`${favoriteGenre} road trip`,
|
||||||
|
`${favoriteGenre} hits`,
|
||||||
|
`${favoriteGenre} travel songs`,
|
||||||
|
`${favoriteGenre} driving music`,
|
||||||
|
`${favoriteGenre} playlist`,
|
||||||
|
`${favoriteGenre} ${destination}`,
|
||||||
|
]
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const tripSpecificQueries = [
|
||||||
|
`${destination} road trip`,
|
||||||
|
`${origin} to ${destination} music`,
|
||||||
|
`${tripName} playlist`,
|
||||||
|
`${destination} travel songs`,
|
||||||
|
`${origin} ${destination} road trip`,
|
||||||
|
];
|
||||||
|
|
||||||
|
const fallbackQueries = [
|
||||||
|
"road trip songs",
|
||||||
|
"travel songs",
|
||||||
|
"summer hits",
|
||||||
|
"top hits Portugal",
|
||||||
|
"pop hits",
|
||||||
|
"driving music",
|
||||||
|
"feel good road trip",
|
||||||
|
"european travel music",
|
||||||
|
"party road trip",
|
||||||
|
"indie road trip",
|
||||||
|
];
|
||||||
|
|
||||||
|
const firstQueries = [
|
||||||
|
...favoriteGenreQueries,
|
||||||
|
...aiQueries,
|
||||||
|
]
|
||||||
|
.map(cleanSearchQuery)
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
const remainingQueries = [
|
||||||
|
...tripSpecificQueries,
|
||||||
|
...fallbackQueries,
|
||||||
|
]
|
||||||
|
.map(cleanSearchQuery)
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
const searchQueries = Array.from(
|
||||||
|
new Set([
|
||||||
|
...firstQueries,
|
||||||
|
...shuffleArray(remainingQueries),
|
||||||
|
])
|
||||||
|
);
|
||||||
|
|
||||||
|
const playlistRandomSeed = `${Date.now()}-${Math.random()
|
||||||
|
.toString(36)
|
||||||
|
.slice(2)}`;
|
||||||
|
|
||||||
|
console.log("PLAYLIST_RANDOM_SEED:", playlistRandomSeed);
|
||||||
|
console.log("PLAYLIST_MUSIC_QUERIES:", searchQueries);
|
||||||
|
|
||||||
// D. Create empty playlist
|
// D. Create empty playlist
|
||||||
const createPlaylistUrl = 'https://api.spotify.com/v1/me/playlists';
|
const createPlaylistUrl = "https://api.spotify.com/v1/me/playlists";
|
||||||
const createPlaylistBody = JSON.stringify({
|
const createPlaylistBody = JSON.stringify({
|
||||||
name: tripName,
|
name: tripName,
|
||||||
description: `Roadtrip from ${origin} to ${destination}. Themes: ${searchQueries.join(', ')}`,
|
description: `Roadtrip from ${origin} to ${destination}. Themes: ${searchQueries
|
||||||
public: false
|
.slice(0, 8)
|
||||||
|
.join(", ")}`,
|
||||||
|
public: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log("CREATE_PLAYLIST_URL:", createPlaylistUrl);
|
console.log("CREATE_PLAYLIST_URL:", createPlaylistUrl);
|
||||||
console.log("CREATE_PLAYLIST_BODY:", createPlaylistBody);
|
console.log("CREATE_PLAYLIST_BODY:", createPlaylistBody);
|
||||||
|
|
||||||
const createPlaylistRes = await fetch(createPlaylistUrl, {
|
const createPlaylistRes = await fetch(createPlaylistUrl, {
|
||||||
method: 'POST',
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
'Authorization': `Bearer ${providerToken}`,
|
Authorization: `Bearer ${providerToken}`,
|
||||||
'Content-Type': 'application/json'
|
"Content-Type": "application/json",
|
||||||
},
|
},
|
||||||
body: createPlaylistBody
|
body: createPlaylistBody,
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log("CREATE_PLAYLIST_HTTP_STATUS:", createPlaylistRes.status);
|
console.log("CREATE_PLAYLIST_HTTP_STATUS:", createPlaylistRes.status);
|
||||||
|
|
||||||
const createPlaylistResText = await createPlaylistRes.text();
|
const createPlaylistResText = await createPlaylistRes.text();
|
||||||
|
|
||||||
if (!createPlaylistRes.ok) {
|
if (!createPlaylistRes.ok) {
|
||||||
console.log("CREATE_PLAYLIST_RESPONSE_BODY_IF_FAILED:", createPlaylistResText.substring(0, 300));
|
console.log(
|
||||||
|
"CREATE_PLAYLIST_RESPONSE_BODY_IF_FAILED:",
|
||||||
|
createPlaylistResText.substring(0, 300)
|
||||||
|
);
|
||||||
|
|
||||||
if (createPlaylistRes.status === 403) {
|
if (createPlaylistRes.status === 403) {
|
||||||
// Stored refresh token predates playlist scopes — clear tokens so next login forces full re-auth
|
|
||||||
await clearSpotifyTokens();
|
await clearSpotifyTokens();
|
||||||
console.warn("CREATE_PLAYLIST_403: Cleared stale Spotify tokens. User must reconnect.");
|
|
||||||
Alert.alert(
|
console.warn(
|
||||||
'Permissão Spotify Necessária',
|
"CREATE_PLAYLIST_403: Cleared stale Spotify tokens. User must reconnect."
|
||||||
'Reconnect Spotify to grant playlist permissions. Go to Profile and log in with Spotify again.'
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
Alert.alert(
|
||||||
|
"Permissão Spotify Necessária",
|
||||||
|
"Reconecta o Spotify para dar permissão de criar playlists."
|
||||||
|
);
|
||||||
|
|
||||||
playlistCreationFailed = true;
|
playlistCreationFailed = true;
|
||||||
playlistFailureReason = 'scope';
|
playlistFailureReason = "scope";
|
||||||
// Skip further playlist work — trip will still be saved
|
|
||||||
throw new Error(`CREATE_PLAYLIST_SCOPE_ERROR: Tokens cleared, re-auth required.`);
|
throw new Error(
|
||||||
|
"CREATE_PLAYLIST_SCOPE_ERROR: Tokens cleared, re-auth required."
|
||||||
|
);
|
||||||
}
|
}
|
||||||
throw new Error(`Spotify API returned status ${createPlaylistRes.status} for CreatePlaylist: ${createPlaylistResText.substring(0, 150)}`);
|
|
||||||
|
throw new Error(
|
||||||
|
`Spotify API returned status ${createPlaylistRes.status
|
||||||
|
} for CreatePlaylist: ${createPlaylistResText.substring(0, 150)}`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
let playlistData: any;
|
let playlistData: any;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
playlistData = JSON.parse(createPlaylistResText);
|
playlistData = JSON.parse(createPlaylistResText);
|
||||||
} catch (e) {
|
} catch {
|
||||||
throw new Error(`Failed to parse JSON response for CreatePlaylist: ${createPlaylistResText.substring(0, 150)}`);
|
throw new Error(
|
||||||
|
`Failed to parse JSON response for CreatePlaylist: ${createPlaylistResText.substring(
|
||||||
|
0,
|
||||||
|
150
|
||||||
|
)}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!playlistData.id) {
|
||||||
|
throw new Error("Could not create playlist");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!playlistData.id) throw new Error('Could not create playlist');
|
|
||||||
const playlistId = playlistData.id;
|
const playlistId = playlistData.id;
|
||||||
generatedPlaylistUrl = playlistData.external_urls.spotify;
|
generatedPlaylistUrl = playlistData.external_urls.spotify;
|
||||||
|
|
||||||
// E. Fill playlist with tracks based on duration
|
// E. Fill playlist with varied tracks based on duration
|
||||||
console.log("TARGET_PLAYLIST_DURATION_MS:", tripDurationMs);
|
console.log("TARGET_PLAYLIST_DURATION_MS:", tripDurationMs);
|
||||||
|
|
||||||
let accumulatedDurationMs = 0;
|
let accumulatedDurationMs = 0;
|
||||||
let selectedTracks: { id: string; uri: string; duration_ms: number }[] = [];
|
const selectedTracks: SelectedSpotifyTrack[] = [];
|
||||||
|
const selectedTrackIds = new Set<string>();
|
||||||
|
const artistCount = new Map<string, number>();
|
||||||
|
|
||||||
let searchRequestsCount = 0;
|
let searchRequestsCount = 0;
|
||||||
|
let queryIndex = 0;
|
||||||
|
|
||||||
const MAX_SEARCH_REQUESTS = 40;
|
const MAX_SEARCH_REQUESTS = 40;
|
||||||
const MAX_TRACKS = 400;
|
const MAX_TRACKS = 400;
|
||||||
let queryIndex = 0;
|
const MAX_TRACKS_PER_ARTIST = 3;
|
||||||
let offset = 0;
|
|
||||||
let noMoreTracks = false;
|
|
||||||
|
|
||||||
while (
|
while (
|
||||||
accumulatedDurationMs < tripDurationMs &&
|
accumulatedDurationMs < tripDurationMs &&
|
||||||
searchRequestsCount < MAX_SEARCH_REQUESTS &&
|
searchRequestsCount < MAX_SEARCH_REQUESTS &&
|
||||||
selectedTracks.length < MAX_TRACKS &&
|
selectedTracks.length < MAX_TRACKS &&
|
||||||
!noMoreTracks
|
searchQueries.length > 0
|
||||||
) {
|
) {
|
||||||
const currentQuery = searchQueries[queryIndex % searchQueries.length];
|
const currentQuery = searchQueries[queryIndex % searchQueries.length];
|
||||||
const queryEncoded = encodeURIComponent(currentQuery);
|
const queryEncoded = encodeURIComponent(currentQuery);
|
||||||
const searchUrl = `https://api.spotify.com/v1/search?type=track&q=${queryEncoded}&limit=10&market=${spotifyUserCountry}&offset=${offset}`;
|
const offset = getRandomSpotifyOffset();
|
||||||
|
|
||||||
console.log("TRACK_SEARCH_QUERY:", currentQuery, "offset:", offset);
|
const searchUrl =
|
||||||
|
`https://api.spotify.com/v1/search` +
|
||||||
|
`?type=track` +
|
||||||
|
`&q=${queryEncoded}` +
|
||||||
|
`&limit=10` +
|
||||||
|
`&market=${spotifyUserCountry}` +
|
||||||
|
`&offset=${offset}`;
|
||||||
|
|
||||||
|
console.log("TRACK_SEARCH_QUERY:", currentQuery);
|
||||||
|
console.log("TRACK_SEARCH_OFFSET:", offset);
|
||||||
console.log("TRACK_SEARCH_URL:", searchUrl);
|
console.log("TRACK_SEARCH_URL:", searchUrl);
|
||||||
|
|
||||||
const searchRes = await fetch(searchUrl, {
|
const searchRes = await fetch(searchUrl, {
|
||||||
headers: {
|
headers: {
|
||||||
'Authorization': `Bearer ${providerToken}`,
|
Authorization: `Bearer ${providerToken}`,
|
||||||
'Content-Type': 'application/json'
|
"Content-Type": "application/json",
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log("TRACK_SEARCH_STATUS:", searchRes.status);
|
console.log("TRACK_SEARCH_STATUS:", searchRes.status);
|
||||||
|
|
||||||
if (!searchRes.ok) {
|
if (!searchRes.ok) {
|
||||||
const errText = await searchRes.text();
|
const errText = await searchRes.text();
|
||||||
console.warn("Spotify search failed:", searchRes.status, errText.substring(0, 150));
|
|
||||||
console.log("TRACK_SEARCH_RESPONSE_BODY_IF_FAILED:", errText.substring(0, 300));
|
console.warn(
|
||||||
queryIndex++;
|
"Spotify search failed:",
|
||||||
offset = 0;
|
searchRes.status,
|
||||||
searchRequestsCount++;
|
errText.substring(0, 150)
|
||||||
continue;
|
);
|
||||||
}
|
|
||||||
|
console.log(
|
||||||
const searchData = await safeParseJson(searchRes, 'SearchTracks');
|
"TRACK_SEARCH_RESPONSE_BODY_IF_FAILED:",
|
||||||
const tracks = searchData?.tracks?.items || [];
|
errText.substring(0, 300)
|
||||||
console.log("TRACKS_RAW_FOUND_COUNT:", tracks.length);
|
);
|
||||||
|
|
||||||
if (tracks.length === 0) {
|
searchRequestsCount++;
|
||||||
queryIndex++;
|
queryIndex++;
|
||||||
offset = 0;
|
continue;
|
||||||
if (queryIndex >= searchQueries.length * 3) {
|
|
||||||
noMoreTracks = true;
|
|
||||||
}
|
|
||||||
searchRequestsCount++;
|
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
|
const searchData = (await safeParseJson(searchRes, "SearchTracks")) as any;
|
||||||
|
|
||||||
|
const rawTracks: SpotifySearchTrack[] = Array.isArray(searchData?.tracks?.items)
|
||||||
|
? (searchData.tracks.items as SpotifySearchTrack[])
|
||||||
|
: [];
|
||||||
|
|
||||||
|
console.log("TRACKS_RAW_FOUND_COUNT:", rawTracks.length);
|
||||||
|
|
||||||
|
const shuffledTracks = shuffleArray<SpotifySearchTrack>(rawTracks);
|
||||||
|
|
||||||
let tracksAfterFilter = 0;
|
let tracksAfterFilter = 0;
|
||||||
|
|
||||||
for (const track of tracks) {
|
for (const track of shuffledTracks) {
|
||||||
if (selectedTracks.length >= MAX_TRACKS) break;
|
if (selectedTracks.length >= MAX_TRACKS) break;
|
||||||
if (accumulatedDurationMs >= tripDurationMs) break;
|
if (accumulatedDurationMs >= tripDurationMs) break;
|
||||||
|
|
||||||
if (track.id && track.uri && track.duration_ms && track.is_local !== true) {
|
const trackId = track.id;
|
||||||
if (track.is_playable === undefined || track.is_playable !== false) {
|
const trackUri = track.uri;
|
||||||
if (!selectedTracks.some(t => t.id === track.id)) {
|
const trackDurationMs = track.duration_ms;
|
||||||
selectedTracks.push({ id: track.id, uri: track.uri, duration_ms: track.duration_ms });
|
|
||||||
accumulatedDurationMs += track.duration_ms;
|
if (!trackId) continue;
|
||||||
tracksAfterFilter++;
|
if (!trackUri) continue;
|
||||||
}
|
if (!trackDurationMs) continue;
|
||||||
}
|
if (track.is_local === true) continue;
|
||||||
}
|
if (track.is_playable === false) continue;
|
||||||
|
if (selectedTrackIds.has(trackId)) continue;
|
||||||
|
|
||||||
|
const artistKey = getMainArtistKey(track);
|
||||||
|
const currentArtistCount = artistCount.get(artistKey) ?? 0;
|
||||||
|
|
||||||
|
if (currentArtistCount >= MAX_TRACKS_PER_ARTIST) continue;
|
||||||
|
|
||||||
|
selectedTracks.push({
|
||||||
|
id: trackId,
|
||||||
|
uri: trackUri,
|
||||||
|
duration_ms: trackDurationMs,
|
||||||
|
});
|
||||||
|
|
||||||
|
selectedTrackIds.add(trackId);
|
||||||
|
artistCount.set(artistKey, currentArtistCount + 1);
|
||||||
|
|
||||||
|
accumulatedDurationMs += trackDurationMs;
|
||||||
|
tracksAfterFilter++;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log("TRACKS_AFTER_FILTER_COUNT:", tracksAfterFilter);
|
console.log("TRACKS_AFTER_FILTER_COUNT:", tracksAfterFilter);
|
||||||
|
console.log("TRACKS_SELECTED_COUNT:", selectedTracks.length);
|
||||||
offset += 10;
|
console.log("UNIQUE_ARTISTS_COUNT:", artistCount.size);
|
||||||
if (offset >= 1000) {
|
console.log("SELECTED_TRACKS_TOTAL_DURATION_MS:", accumulatedDurationMs);
|
||||||
queryIndex++;
|
|
||||||
offset = 0;
|
|
||||||
}
|
|
||||||
searchRequestsCount++;
|
searchRequestsCount++;
|
||||||
|
queryIndex++;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log("TRACKS_SELECTED_COUNT:", selectedTracks.length);
|
console.log("TRACKS_SELECTED_COUNT:", selectedTracks.length);
|
||||||
@@ -298,48 +612,69 @@ export default function NewTripScreen({ navigation }) {
|
|||||||
|
|
||||||
if (selectedTracks.length > 0) {
|
if (selectedTracks.length > 0) {
|
||||||
// F. Add tracks to playlist in chunks
|
// F. Add tracks to playlist in chunks
|
||||||
const trackUris = selectedTracks.map(t => t.uri);
|
const trackUris = selectedTracks.map((track) => track.uri);
|
||||||
const chunkSize = 100;
|
const chunkSize = 100;
|
||||||
let tracksAddedSuccessfully = true;
|
let tracksAddedSuccessfully = true;
|
||||||
|
|
||||||
for (let i = 0; i < trackUris.length; i += chunkSize) {
|
for (let i = 0; i < trackUris.length; i += chunkSize) {
|
||||||
const chunk = trackUris.slice(i, i + chunkSize);
|
const chunk = trackUris.slice(i, i + chunkSize);
|
||||||
|
|
||||||
const addTracksRes = await fetch(`https://api.spotify.com/v1/playlists/${playlistId}/items`, {
|
const addTracksRes = await fetch(
|
||||||
method: 'POST',
|
`https://api.spotify.com/v1/playlists/${playlistId}/items`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
'Authorization': `Bearer ${providerToken}`,
|
Authorization: `Bearer ${providerToken}`,
|
||||||
'Content-Type': 'application/json'
|
"Content-Type": "application/json",
|
||||||
},
|
},
|
||||||
body: JSON.stringify({ uris: chunk })
|
body: JSON.stringify({ uris: chunk }),
|
||||||
});
|
|
||||||
|
|
||||||
console.log("ADD_TRACKS_HTTP_STATUS:", addTracksRes.status);
|
|
||||||
if (!addTracksRes.ok) {
|
|
||||||
const addTracksErr = await addTracksRes.text();
|
|
||||||
console.log("ADD_TRACKS_RESPONSE_BODY_IF_FAILED:", addTracksErr.substring(0, 300));
|
|
||||||
tracksAddedSuccessfully = false;
|
|
||||||
throw new Error(`Spotify API returned status ${addTracksRes.status} while adding tracks: ${addTracksErr.substring(0, 150)}`);
|
|
||||||
}
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log("ADD_TRACKS_HTTP_STATUS:", addTracksRes.status);
|
||||||
|
|
||||||
|
if (!addTracksRes.ok) {
|
||||||
|
const addTracksErr = await addTracksRes.text();
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
"ADD_TRACKS_RESPONSE_BODY_IF_FAILED:",
|
||||||
|
addTracksErr.substring(0, 300)
|
||||||
|
);
|
||||||
|
|
||||||
|
tracksAddedSuccessfully = false;
|
||||||
|
|
||||||
|
throw new Error(
|
||||||
|
`Spotify API returned status ${addTracksRes.status
|
||||||
|
} while adding tracks: ${addTracksErr.substring(0, 150)}`
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (tracksAddedSuccessfully) {
|
if (tracksAddedSuccessfully) {
|
||||||
console.log("PLAYLIST_CREATE_SUCCESS:", generatedPlaylistUrl);
|
console.log("PLAYLIST_CREATE_SUCCESS:", generatedPlaylistUrl);
|
||||||
playlistCreationFailed = false;
|
|
||||||
|
playlistCreationFailed = false;
|
||||||
if (accumulatedDurationMs < tripDurationMs - 60000 && (selectedTracks.length >= MAX_TRACKS || searchRequestsCount >= MAX_SEARCH_REQUESTS || noMoreTracks)) {
|
|
||||||
const hours = Math.round((accumulatedDurationMs / 3600000) * 10) / 10;
|
if (
|
||||||
if (hours >= 1) {
|
accumulatedDurationMs < tripDurationMs - 60000 &&
|
||||||
playlistSuccessMessage = `Viagem guardada! Playlist criada com ${hours} horas de música.`;
|
(selectedTracks.length >= MAX_TRACKS ||
|
||||||
} else {
|
searchRequestsCount >= MAX_SEARCH_REQUESTS)
|
||||||
const minutes = Math.round(accumulatedDurationMs / 60000);
|
) {
|
||||||
playlistSuccessMessage = `Viagem guardada! Playlist criada com ${minutes} minutos de música.`;
|
const hours = Math.round((accumulatedDurationMs / 3600000) * 10) / 10;
|
||||||
}
|
|
||||||
}
|
if (hours >= 1) {
|
||||||
|
playlistSuccessMessage = `Viagem guardada! Playlist criada com ${hours} horas de música.`;
|
||||||
|
} else {
|
||||||
|
const minutes = Math.round(accumulatedDurationMs / 60000);
|
||||||
|
playlistSuccessMessage = `Viagem guardada! Playlist criada com ${minutes} minutos de música.`;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
console.warn("No tracks found for queries:", searchQueries);
|
console.warn("No tracks found for queries:", searchQueries);
|
||||||
playlistCreationFailed = true;
|
|
||||||
playlistFailureReason = 'notracks';
|
playlistCreationFailed = true;
|
||||||
|
playlistFailureReason = "notracks";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (playlistError: any) {
|
} catch (playlistError: any) {
|
||||||
@@ -354,36 +689,36 @@ export default function NewTripScreen({ navigation }) {
|
|||||||
try {
|
try {
|
||||||
const { data: { session } } = await supabase.auth.getSession();
|
const { data: { session } } = await supabase.auth.getSession();
|
||||||
const userId = session?.user?.id || null;
|
const userId = session?.user?.id || null;
|
||||||
|
|
||||||
const { error: dbError } = await supabase.from('trips').insert({
|
const { error: dbError } = await supabase.from('trips').insert({
|
||||||
user_id: userId,
|
user_id: userId,
|
||||||
title: tripName,
|
title: tripName,
|
||||||
origin,
|
origin,
|
||||||
destination,
|
destination,
|
||||||
distance: finalDistance,
|
distance: finalDistance,
|
||||||
duration: finalDuration,
|
duration: finalDuration,
|
||||||
playlist_url: generatedPlaylistUrl
|
playlist_url: generatedPlaylistUrl
|
||||||
});
|
});
|
||||||
|
|
||||||
if (dbError) {
|
if (dbError) {
|
||||||
console.error("DB Insert error:", dbError);
|
console.error("DB Insert error:", dbError);
|
||||||
Alert.alert('Erro ao Guardar', 'Não foi possível guardar a viagem na base de dados: ' + dbError.message);
|
Alert.alert('Erro ao Guardar', 'Não foi possível guardar a viagem na base de dados: ' + dbError.message);
|
||||||
} else {
|
} else {
|
||||||
if (playlistCreationFailed) {
|
if (playlistCreationFailed) {
|
||||||
if (playlistFailureReason === 'notracks') {
|
if (playlistFailureReason === 'notracks') {
|
||||||
Alert.alert('Sucesso!', 'Playlist criada, mas não foram encontradas músicas para adicionar.');
|
Alert.alert('Sucesso!', 'Playlist criada, mas não foram encontradas músicas para adicionar.');
|
||||||
} else {
|
} else {
|
||||||
Alert.alert('Sucesso!', 'Viagem guardada! A playlist do Spotify não pôde ser criada, mas a viagem foi guardada.');
|
Alert.alert('Sucesso!', 'Viagem guardada! A playlist do Spotify não pôde ser criada, mas a viagem foi guardada.');
|
||||||
}
|
}
|
||||||
} else if (generatedPlaylistUrl) {
|
} else if (generatedPlaylistUrl) {
|
||||||
Alert.alert('Sucesso!', playlistSuccessMessage);
|
Alert.alert('Sucesso!', playlistSuccessMessage);
|
||||||
} else {
|
} else {
|
||||||
Alert.alert('Sucesso!', 'Viagem calculada e guardada!');
|
Alert.alert('Sucesso!', 'Viagem calculada e guardada!');
|
||||||
}
|
}
|
||||||
navigation.goBack();
|
navigation.goBack();
|
||||||
}
|
}
|
||||||
} catch (dbEx) {
|
} catch (dbEx) {
|
||||||
console.error("Exception during DB save:", dbEx);
|
console.error("Exception during DB save:", dbEx);
|
||||||
}
|
}
|
||||||
|
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
Reference in New Issue
Block a user