• Handles a track change event

    Central handler for track change events that coordinates the entire track change analysis and processing pipeline. This function:

    1. Logs the track change for monitoring
    2. Retrieves relevant playback state information
    3. Checks for edge cases that might affect analysis
    4. Identifies backward navigation vs. forward progression
    5. Analyzes if the change was a skip or normal completion
    6. Determines if the skip was manual or automatic
    7. Records skip events with detailed metadata
    8. Updates history tracking for future analysis

    Parameters

    • newTrackId: string

      Spotify ID of the new track being played

    Returns Promise<void>

    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");
    }
    }