Spotify ID of the new track being played
Promise that resolves when processing is complete
// Process a track change when detected
async function onTrackChanged(newTrackId) {
await handleTrackChange(newTrackId);
updateUIForNewTrack();
}
export async function handleTrackChange(newTrackId: string): Promise<void> {
try {
// Log the new track ID for context
store.saveLog(`Processing track change to ID: ${newTrackId}`, "DEBUG");
const state = getPlaybackState();
const previousTrackId = state.currentTrackId;
const lastProgress = state.lastProgress || 0;
const duration = state.currentTrackDuration || 0;
// Skip handling if there was no previous track
if (!previousTrackId) {
store.saveLog("No previous track to evaluate for skip", "DEBUG");
addToLocalHistory(newTrackId); // Still add to history
return;
}
// Get settings for skip threshold
const settings = store.getSettings();
const skipProgressThreshold = settings.skipProgress / 100 || 0.7; // Convert from percentage to decimal
// Calculate progress as a percentage for evaluation
const progressPercent = duration > 0 ? lastProgress / duration : 0;
// Track change detected, evaluate if it was a skip or completion
store.saveLog(
`Track changed from "${state.currentTrackName}" (${previousTrackId}) to a new track (${newTrackId}) at ${(progressPercent * 100).toFixed(1)}% progress`,
"DEBUG",
);
// Check if this is a repeated track (same track playing again)
const isRepeatedTrack = previousTrackId === newTrackId;
if (isRepeatedTrack) {
store.saveLog(
`Track "${state.currentTrackName}" is repeating - not a skip`,
"DEBUG",
);
return;
}
// Handle track change edge cases first
const edgeCaseResult = handleTrackChangeEdgeCases(
state,
getPlaybackState(), // Current state
);
if (edgeCaseResult.isEdgeCase) {
store.saveLog(`Detected edge case: ${edgeCaseResult.edgeCase}`, "DEBUG");
if (edgeCaseResult.shouldIgnore) {
store.saveLog(
`Ignoring track change due to edge case: ${edgeCaseResult.edgeCase}`,
"DEBUG",
);
addToLocalHistory(newTrackId); // Still add to history
return;
}
}
// Step 1: Check our local history with improved logic that considers timing and sequence
const isBackwardNavigation = isBackwardNavigationInLocalHistory(
newTrackId,
previousTrackId,
progressPercent,
);
// Step 2: Get recently played tracks from Spotify API as backup
const recentlyPlayed = await spotifyApi.getRecentlyPlayedTracks();
// Step 3: Check if new track appears before previous track in Spotify history
// (this is our original approach, kept as a fallback)
let isPreviousTrackNavigationAPI = false;
if (recentlyPlayed?.items) {
const newTrackIndex = recentlyPlayed.items.findIndex(
(item) => item.track.id === newTrackId,
);
const previousTrackIndex = recentlyPlayed.items.findIndex(
(item) => item.track.id === previousTrackId,
);
// If both tracks are in recently played AND the new track appears before the previous track
// in the history, this is likely a "previous track" navigation
if (
newTrackIndex !== -1 &&
previousTrackIndex !== -1 &&
newTrackIndex < previousTrackIndex &&
previousTrackIndex - newTrackIndex < 3 // They're close together in history
) {
isPreviousTrackNavigationAPI = true;
store.saveLog(
`API history indicates backward navigation: "${newTrackId}" (position ${newTrackIndex}) from "${previousTrackId}" (position ${previousTrackIndex})`,
"DEBUG",
);
}
}
// Also check if the previous track itself is in recently played
const isPreviousTrackInRecentlyPlayed = recentlyPlayed?.items?.some(
(item) => item.track.id === previousTrackId,
);
// Add the current track to our history AFTER all the checks
addToLocalHistory(newTrackId);
// Special handling for skipping forward from a track we previously navigated back to
if (
lastNavigatedToTrackId === previousTrackId &&
Date.now() - lastNavigationTimestamp < NAVIGATION_EXPIRY_TIME &&
progressPercent < 0.3 // Less than 30% through the track
) {
// We previously navigated back to this track, and now we're skipping forward from it
// This should be counted as a skip, not a navigation
store.saveLog(
`User skipping forward from a previously navigated-to track (${previousTrackId}) - counting as skip`,
"DEBUG",
);
// Continue to skip handling below - don't return or mark as navigation
}
// Only if NOT skipping from a previously navigated track, check backward navigation
else {
// Combine all our detection signals with more flexible logic
const isNavigatingBackward =
isBackwardNavigation || isPreviousTrackNavigationAPI;
// Log the decision factors more clearly
store.saveLog(
`Navigation detection: local=${isBackwardNavigation}, API=${isPreviousTrackNavigationAPI}, inRecentlyPlayed=${isPreviousTrackInRecentlyPlayed}`,
"DEBUG",
);
// If we detected backward navigation, don't count it as a skip
if (isNavigatingBackward) {
store.saveLog(
`Track change for "${state.currentTrackName}" detected as backward navigation - not counted as skip`,
"DEBUG",
);
return;
}
}
// Now use our enhanced position-based skip detection
const skipAnalysis = analyzePositionBasedSkip(state, skipProgressThreshold);
// Record this skip for pattern analysis regardless of library status
if (skipAnalysis.isSkip) {
recordSkipForPatternAnalysis(previousTrackId, progressPercent);
}
// Determine if it was a manual action or automatic
let skipTypeInfo = {} as {
isManual: boolean;
confidence: number;
reason: string;
};
if (skipAnalysis.isSkip) {
skipTypeInfo = detectManualVsAutoSkip(
getPlaybackState(), // Current state
state, // Previous state
);
store.saveLog(
`Skip type detection: ${skipTypeInfo.isManual ? "manual" : "automatic"} (${skipTypeInfo.confidence.toFixed(2)} confidence) - ${skipTypeInfo.reason}`,
"DEBUG",
);
}
// If track changed before the skip threshold, consider it skipped
if (skipAnalysis.isSkip) {
// Check if the track is in the user's library (log for informational purposes)
if (!state.isInLibrary) {
store.saveLog(
`Track "${state.currentTrackName}" was skipped but not in library - tracking skip anyway`,
"DEBUG",
);
} else {
store.saveLog(
`Track "${state.currentTrackName}" was skipped at ${(progressPercent * 100).toFixed(1)}% (threshold: ${skipProgressThreshold * 100}%): ${skipAnalysis.reason}`,
"INFO",
);
}
// Record this as a skipped track
await recordSkippedTrack({
id: previousTrackId,
name: state.currentTrackName || "",
artist: state.currentArtistName || "",
album: state.currentAlbumName || "",
skippedAt: Date.now(),
playDuration: lastProgress,
trackDuration: duration,
playPercentage: Math.round(progressPercent * 100),
deviceId: state.deviceId || undefined,
deviceName: state.deviceName || undefined,
skipType: skipAnalysis.skipType,
isManualSkip: skipTypeInfo.isManual,
confidence: skipTypeInfo.confidence,
reason: skipAnalysis.reason,
isInLibrary: state.isInLibrary || false,
});
// Record enhanced statistics for skipped track
try {
// Get track artist ID (we'll retrieve only the first one for simplicity)
const trackInfo = await spotifyApi.getTrack(previousTrackId);
const artistId = trackInfo?.artists?.[0]?.id || "unknown";
const artistName =
trackInfo?.artists?.[0]?.name ||
state.currentArtistName ||
"Unknown Artist";
const trackName =
trackInfo?.name || state.currentTrackName || "Unknown Track";
const trackDuration = trackInfo?.duration_ms || duration || 0;
// Update stats with skipped track information
await store.updateTrackStatistics(
previousTrackId,
trackName,
artistId,
artistName,
trackDuration,
true, // was skipped
lastProgress,
state.currentDeviceName || null,
state.currentDeviceType || null,
Date.now(),
skipAnalysis.skipType,
skipTypeInfo.isManual,
);
store.saveLog(
`Enhanced statistics recorded for skipped track "${trackName}" (${skipAnalysis.skipType})`,
"DEBUG",
);
} catch (error) {
store.saveLog(
`Failed to record enhanced skip statistics: ${error}`,
"ERROR",
);
}
// If auto-unlike is enabled and we've reached the skip threshold, only unlike the track if it's in the library
const skippedTracks = await store.getSkippedTracks();
const trackData = skippedTracks.find(
(track) => track.id === previousTrackId,
);
if (
trackData &&
settings.autoUnlike &&
(trackData.skipCount || 0) >= settings.skipThreshold &&
state.isInLibrary // Only attempt to unlike if it's in the library
) {
try {
const result = await spotifyApi.unlikeTrack(previousTrackId, true);
if (result) {
store.saveLog(
`Auto-removed track "${state.currentTrackName}" from library (skipped ${trackData.skipCount} times)`,
"INFO",
);
}
} catch (error) {
store.saveLog(`Failed to unlike track: ${error}`, "ERROR");
}
}
} else {
// Track was played through to completion (or close enough)
store.saveLog(
`Track "${state.currentTrackName}" completed (${(progressPercent * 100).toFixed(1)}%)`,
"DEBUG",
);
// Record enhanced statistics for completed track
try {
// Get track artist ID
const trackInfo = await spotifyApi.getTrack(previousTrackId);
const artistId = trackInfo?.artists?.[0]?.id || "unknown";
const artistName =
trackInfo?.artists?.[0]?.name ||
state.currentArtistName ||
"Unknown Artist";
const trackName =
trackInfo?.name || state.currentTrackName || "Unknown Track";
const trackDuration = trackInfo?.duration_ms || duration || 0;
// Calculate actual played duration - for completed tracks, use the full duration or last progress
// whichever is greater (in case the progress reporting caught it a bit before the end)
const actualPlayedDuration = Math.max(
lastProgress,
trackDuration * 0.98,
);
// Update statistics with completed track information
await store.updateTrackStatistics(
previousTrackId,
trackName,
artistId,
artistName,
trackDuration,
false, // not skipped
actualPlayedDuration, // Use calculated actual played duration
state.currentDeviceName || null,
state.currentDeviceType || null,
Date.now(),
);
store.saveLog(
`Enhanced statistics recorded for completed track "${trackName}"`,
"DEBUG",
);
} catch (error) {
store.saveLog(
`Failed to record enhanced completion statistics: ${error}`,
"ERROR",
);
}
// Update not-skipped count (previously tracked)
try {
await store.updateNotSkippedTrack({
id: previousTrackId,
name: state.currentTrackName || "",
artist: state.currentArtistName || "",
skipCount: 0,
notSkippedCount: 1,
lastSkipped: "",
});
store.saveLog(
`Recorded completed play for "${state.currentTrackName}"`,
"DEBUG",
);
} catch (error) {
store.saveLog(`Failed to update not-skipped count: ${error}`, "ERROR");
}
}
} catch (error) {
store.saveLog(`Error handling track change: ${error}`, "ERROR");
}
}
Handles a track change event
Central handler for track change events that coordinates the entire track change analysis and processing pipeline. This function: