Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .changeset/smooth-music-flow.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
"MiniFy": minor
---

Improved continuous music playback experience

- Added Spotify playlist context playback for seamless auto-advance through playlists
- Implemented YouTube playback queue service for automatic track advancement
- Added autoplay service (Random Queue) that plays recommendations after playlist/song ends
- Added Spotify keep-alive service to prevent connection timeouts after inactivity
- Refined AI DJ to better distinguish single track requests from continuous queue requests
- Fixed AI Queue border to only show when explicit AI Queue is active
15 changes: 14 additions & 1 deletion apps/desktop/src/hooks/useCurrentlyPlaying.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { invoke } from "@tauri-apps/api/core";
import { useEffect, useRef, useState } from "react";
import { useAIQueueStore } from "../lib/aiQueueStore";
import { startAutoplayMonitor, stopAutoplayMonitor } from "../lib/playback/autoplayService";
import { startKeepAlive, stopKeepAlive } from "../lib/playback/spotifyKeepAlive";
import {
type LastPlayedTrack,
type MusicProviderType,
Expand Down Expand Up @@ -133,7 +135,7 @@ export function useCurrentlyPlaying(pollMs = 3000) {
const initialLoadDone = useRef<boolean>(false);
const cachedTrackLoaded = useRef<boolean>(false);

// Load cached track on mount
// Load cached track on mount and start services
useEffect(() => {
if (cachedTrackLoaded.current) return;
cachedTrackLoaded.current = true;
Expand All @@ -156,10 +158,21 @@ export function useCurrentlyPlaying(pollMs = 3000) {
setState(cacheToPlaybackState(cached));
}
}

// Start playback services
startAutoplayMonitor();
if (provider === "spotify") {
startKeepAlive();
}
Comment on lines +162 to +166
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Start keep-alive regardless of initial provider.

Right now, keep-alive only starts if the initial provider is Spotify. If the user starts on YouTube and later switches to Spotify, keep-alive never starts. Start it unconditionally (it already self-guards by provider type) or add an effect that responds to provider changes.

🔧 Proposed fix (unconditional start)
-        startAutoplayMonitor();
-        if (provider === "spotify") {
-          startKeepAlive();
-        }
+        startAutoplayMonitor();
+        startKeepAlive();
🤖 Prompt for AI Agents
In `@apps/desktop/src/hooks/useCurrentlyPlaying.ts` around lines 162 - 166, The
keep-alive is only started when the initial provider equals "spotify", so
switching to Spotify later never triggers it; update useCurrentlyPlaying to
start the keep-alive unconditionally (call startKeepAlive alongside
startAutoplayMonitor) or add an effect that calls startKeepAlive when provider
changes—since startKeepAlive already self-guards by provider, the simplest fix
is to remove the provider check and invoke startKeepAlive unconditionally where
startAutoplayMonitor is called (refer to startKeepAlive and startAutoplayMonitor
in useCurrentlyPlaying).

} catch (err) {
console.error("Failed to load cached track:", err);
}
})();

return () => {
stopAutoplayMonitor();
stopKeepAlive();
};
}, []);

// Poll for current playback state
Expand Down
113 changes: 57 additions & 56 deletions apps/desktop/src/lib/aiClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,64 +19,65 @@ Example TOON track data:
Save Your Tears,The Weeknd,spotify:track:5QO79kh1waicV47BqGRL3g
Starboy,The Weeknd & Daft Punk,spotify:track:7MXVkk9YMctZqd1Srtv4MB

## Your Capabilities

### AI Queue (Continuous Playback) - IMPORTANT!
- startAIQueueWithMood: Start continuous music playback based on a mood/genre. Use this when users want ongoing music!
- stopAIQueuePlayback: Stop the AI Queue
- getAIQueueStatus: Check if AI Queue is running

**WHEN TO USE AI QUEUE:**
- User says "play music for X" (work, studying, workout, relaxing, etc.)
- User wants continuous/ongoing music without manually selecting tracks
- User mentions "lofi", "background music", "playlist", "mix", or similar
- User says things like "find me music and keep playing" or "play this kind of music for a while"

**EXAMPLES that should trigger AI Queue:**
- "I want calm work music" → startAIQueueWithMood("calm focus music for working")
- "Play lofi beats" → startAIQueueWithMood("lofi hip hop beats for relaxation")
- "I need workout music" → startAIQueueWithMood("high energy workout music")
- "Play something relaxing for the evening" → startAIQueueWithMood("relaxing evening vibes")

**If you're unsure whether to start the queue, ask:** "Should I start the AI Queue to continuously play [mood] music?"

### Playback Control (Single Tracks)
- getCurrentTrack: See what's currently playing
- playTrack: Play a single specific track by its Spotify URI
- searchTracks: Search for tracks by name, artist, or query

### User Music Profile Analysis
- getRecentlyPlayed: View recently played tracks
- getTopTracks: Get most played tracks (short_term=4 weeks, medium_term=6 months, long_term=years)
- getTopArtists: Get favorite artists with their genres
- getMusicTaste: Deep analysis of listening patterns (energy, mood, danceability, tempo, acousticness)
- getUserProfile: Get account info and library size

### Smart Recommendations
- getRecommendations: Get Spotify-powered recommendations based on seeds and audio targets

## Strategy Guidelines

1. **For continuous playback requests**: Use startAIQueueWithMood - don't play single tracks!
2. **For specific song requests**: Use searchTracks + playTrack
3. **For "play something good"**: Consider AI Queue for ongoing music, or single track for quick play
4. **For mood-based requests**: AI Queue is usually the best choice
5. **When suggesting**: Explain WHY you chose this approach

## Audio Feature Reference
- energy: 0.0 (calm) to 1.0 (intense)
- valence: 0.0 (sad/dark) to 1.0 (happy/cheerful)
- danceability: 0.0 (not danceable) to 1.0 (very danceable)
- tempo: BPM (60-80 slow, 100-130 moderate, 140+ fast)
- acousticness: 0.0 (electronic) to 1.0 (acoustic)
## CRITICAL: Single Track vs AI Queue Decision

### SINGLE TRACK (use playTrack) - DEFAULT CHOICE
Use playTrack for:
- Specific song requests: "play Blinding Lights", "put on Bohemian Rhapsody"
- Artist + song: "play Shape of You by Ed Sheeran"
- "Play this song", "play that track"
- Any request naming a specific song/track
- "Play something by [artist]" (search and play one track)
- Quick requests without mood/continuous keywords

**EXAMPLES - USE playTrack (NOT AI Queue):**
- "Play Blinding Lights" → searchTracks + playTrack
- "Put on some Daft Punk" → searchTracks + playTrack (ONE song)
- "Play that new Taylor Swift song" → searchTracks + playTrack
- "Can you play Starboy?" → searchTracks + playTrack
- "Play something good" → searchTracks + playTrack (recommend ONE track)

### AI QUEUE (use startAIQueueWithMood) - ONLY WHEN EXPLICITLY NEEDED
Use AI Queue ONLY when user explicitly wants continuous/endless music:
- "Play music for working/studying/gym" (activity-based continuous)
- "Start a playlist of..." or "make me a mix of..."
- "Keep playing similar music" or "play more like this"
- "I want background music for..."
- "Start the AI Queue" (explicit request)
- Keywords: "continuous", "keep playing", "for hours", "background music", "mix", "playlist"

**EXAMPLES - USE AI Queue:**
- "Play lofi for studying" → startAIQueueWithMood
- "I need workout music for the next hour" → startAIQueueWithMood
- "Start playing relaxing jazz" → startAIQueueWithMood
- "Keep the music going" → startAIQueueWithMood

### IF UNSURE: Default to playTrack (single song)
The autoplay system will automatically queue similar songs after the track ends.
Only use AI Queue when continuous playback is EXPLICITLY requested.

## Your Tools

### Single Track Playback
- playTrack: Play a specific track (PREFERRED for most requests)
- searchTracks: Search for tracks
- getCurrentTrack: See what's playing

### AI Queue (Continuous Mode)
- startAIQueueWithMood: Start continuous playback (ONLY for explicit continuous requests)
- stopAIQueuePlayback: Stop the queue
- getAIQueueStatus: Check queue status

### User Music Profile
- getRecentlyPlayed: Recent tracks
- getTopTracks: Most played tracks
- getTopArtists: Favorite artists

## Personality
- Be enthusiastic and knowledgeable about music
- Reference specific data from the user's listening history
- Make connections between artists and genres
- Keep responses concise but insightful
- Take action immediately when the user's intent is clear
- If unsure about AI Queue, ask once - don't be overly cautious`;
- Be enthusiastic about music
- Act quickly - don't over-explain
- When user asks to "play X", just play it immediately
- Keep responses short and action-focused`;

export function createAIModel(providerType: AIProviderType, apiKey: string): LanguageModelV1 {
switch (providerType) {
Expand Down
165 changes: 165 additions & 0 deletions apps/desktop/src/lib/playback/autoplayService.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
import { getActiveProvider, getActiveProviderType } from "../../providers";
import type { UnifiedTrack } from "../../providers/types";
import { getRelatedVideos, videoItemToTrackData } from "../../providers/youtube/client";
import {
addToQueue as spotifyAddToQueue,
fetchRecommendations,
getQueue as spotifyGetQueue,
} from "../../ui/spotifyClient";
import { useAIQueueStore } from "../aiQueueStore";
import { usePlaybackQueueStore } from "./playbackQueueStore";

interface AutoplayState {
enabled: boolean;
lastProcessedTrackId: string | null;
pendingAutoplayTracks: string[];
}

const state: AutoplayState = {
enabled: true,
lastProcessedTrackId: null,
pendingAutoplayTracks: [],
};

let autoplayMonitorInterval: ReturnType<typeof setInterval> | null = null;

export function setAutoplayEnabled(enabled: boolean): void {
state.enabled = enabled;
if (enabled) {
startAutoplayMonitor();
} else {
stopAutoplayMonitor();
}
}

export function isAutoplayEnabled(): boolean {
return state.enabled;
}

export function startAutoplayMonitor(): void {
if (autoplayMonitorInterval) return;

autoplayMonitorInterval = setInterval(async () => {
if (!state.enabled) return;

try {
await checkAndTriggerAutoplay();
} catch (err) {
console.error("Autoplay monitor error:", err);
}
}, 5000);
}

export function stopAutoplayMonitor(): void {
if (autoplayMonitorInterval) {
clearInterval(autoplayMonitorInterval);
autoplayMonitorInterval = null;
}
}

async function checkAndTriggerAutoplay(): Promise<void> {
const aiQueueState = useAIQueueStore.getState();
if (aiQueueState.isActive) {
return;
}

const providerType = await getActiveProviderType();
const provider = await getActiveProvider();
const playbackState = await provider.getPlaybackState();

if (!playbackState?.track) return;

const { track, isPlaying, progressMs } = playbackState;
const durationMs = track.durationMs;

if (track.id === state.lastProcessedTrackId) return;

const isNearEnd = durationMs > 0 && progressMs >= durationMs - 10000;

if (!isNearEnd) return;

if (providerType === "spotify") {
await handleSpotifyAutoplay(track);
} else if (providerType === "youtube") {
await handleYouTubeAutoplay(track, isPlaying, progressMs, durationMs);
}

state.lastProcessedTrackId = track.id;
}

async function handleSpotifyAutoplay(currentTrack: UnifiedTrack): Promise<void> {
try {
const queue = await spotifyGetQueue();

if (queue.queue.length > 0) {
return;
}

const recommendations = await fetchRecommendations({
seedTracks: [currentTrack.id],
limit: 10,
});

if (recommendations.length === 0) return;

for (const track of recommendations.slice(0, 5)) {
const uri = `spotify:track:${track.id}`;
await spotifyAddToQueue(uri);
state.pendingAutoplayTracks.push(track.id);
}
} catch (err) {
console.error("Spotify autoplay failed:", err);
}
}

async function handleYouTubeAutoplay(
currentTrack: UnifiedTrack,
isPlaying: boolean,
progressMs: number,
durationMs: number
): Promise<void> {
const playbackQueue = usePlaybackQueueStore.getState();

if (playbackQueue.getRemainingCount() > 0) {
return;
}

const isEnded = !isPlaying && progressMs >= durationMs - 2000;
if (!isEnded) return;

try {
const videoId = currentTrack.id;
const relatedVideos = await getRelatedVideos(videoId, 10);

if (relatedVideos.length === 0) return;

const nextVideo = relatedVideos[0];
const trackData = videoItemToTrackData(nextVideo);

const nextTrack: UnifiedTrack = {
id: trackData.id,
name: trackData.name,
durationMs: trackData.durationMs,
artists: trackData.artists.map((name, idx) => ({ id: `yt-artist-${idx}`, name })),
album: {
id: "youtube-music",
name: trackData.album,
images: trackData.albumArt ? [{ url: trackData.albumArt, width: 640, height: 640 }] : [],
},
uri: trackData.uri,
provider: "youtube",
};

playbackQueue.appendTracks([nextTrack]);

const provider = await getActiveProvider();
await provider.playTrack(nextTrack.uri);
} catch (err) {
console.error("YouTube autoplay failed:", err);
}
}
Comment on lines +115 to +160
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Guard against unknown durations for YouTube end detection.

If durationMs is 0/unknown (e.g., live streams), isEnded can evaluate true and autoplay prematurely. Add a duration guard.

🐛 Proposed fix
-  const isEnded = !isPlaying && progressMs >= durationMs - 2000;
+  if (durationMs <= 0) return;
+  const isEnded = !isPlaying && progressMs >= durationMs - 2000;
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
async function handleYouTubeAutoplay(
currentTrack: UnifiedTrack,
isPlaying: boolean,
progressMs: number,
durationMs: number
): Promise<void> {
const playbackQueue = usePlaybackQueueStore.getState();
if (playbackQueue.getRemainingCount() > 0) {
return;
}
const isEnded = !isPlaying && progressMs >= durationMs - 2000;
if (!isEnded) return;
try {
const videoId = currentTrack.id;
const relatedVideos = await getRelatedVideos(videoId, 10);
if (relatedVideos.length === 0) return;
const nextVideo = relatedVideos[0];
const trackData = videoItemToTrackData(nextVideo);
const nextTrack: UnifiedTrack = {
id: trackData.id,
name: trackData.name,
durationMs: trackData.durationMs,
artists: trackData.artists.map((name, idx) => ({ id: `yt-artist-${idx}`, name })),
album: {
id: "youtube-music",
name: trackData.album,
images: trackData.albumArt ? [{ url: trackData.albumArt, width: 640, height: 640 }] : [],
},
uri: trackData.uri,
provider: "youtube",
};
playbackQueue.appendTracks([nextTrack]);
const provider = await getActiveProvider();
await provider.playTrack(nextTrack.uri);
} catch (err) {
console.error("YouTube autoplay failed:", err);
}
}
async function handleYouTubeAutoplay(
currentTrack: UnifiedTrack,
isPlaying: boolean,
progressMs: number,
durationMs: number
): Promise<void> {
const playbackQueue = usePlaybackQueueStore.getState();
if (playbackQueue.getRemainingCount() > 0) {
return;
}
if (durationMs <= 0) return;
const isEnded = !isPlaying && progressMs >= durationMs - 2000;
if (!isEnded) return;
try {
const videoId = currentTrack.id;
const relatedVideos = await getRelatedVideos(videoId, 10);
if (relatedVideos.length === 0) return;
const nextVideo = relatedVideos[0];
const trackData = videoItemToTrackData(nextVideo);
const nextTrack: UnifiedTrack = {
id: trackData.id,
name: trackData.name,
durationMs: trackData.durationMs,
artists: trackData.artists.map((name, idx) => ({ id: `yt-artist-${idx}`, name })),
album: {
id: "youtube-music",
name: trackData.album,
images: trackData.albumArt ? [{ url: trackData.albumArt, width: 640, height: 640 }] : [],
},
uri: trackData.uri,
provider: "youtube",
};
playbackQueue.appendTracks([nextTrack]);
const provider = await getActiveProvider();
await provider.playTrack(nextTrack.uri);
} catch (err) {
console.error("YouTube autoplay failed:", err);
}
}
🤖 Prompt for AI Agents
In `@apps/desktop/src/lib/playback/autoplayService.ts` around lines 115 - 160, In
handleYouTubeAutoplay, guard against unknown/zero durations before computing
isEnded: if durationMs is 0 or otherwise invalid (<= 0 or not finite) return
early to avoid treating live/unknown streams as ended; add this check just
before const isEnded = ... so the rest of the autoplay logic (getRelatedVideos,
videoItemToTrackData, playbackQueue.appendTracks, provider.playTrack) only runs
for tracks with a valid duration.


export function resetAutoplayState(): void {
state.lastProcessedTrackId = null;
state.pendingAutoplayTracks = [];
}
4 changes: 4 additions & 0 deletions apps/desktop/src/lib/playback/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export * from "./autoplayService";
export * from "./playbackQueueService";
export * from "./playbackQueueStore";
export * from "./spotifyKeepAlive";
Loading
Loading