From ada2d3cc7803b80c6fa0db048eecaf3aa11b4d19 Mon Sep 17 00:00:00 2001 From: Skeptic-systems Date: Mon, 12 Jan 2026 22:47:13 +0100 Subject: [PATCH] feat(playback): enhance playback functionality and service management - Updated useCurrentlyPlaying hook to start and stop autoplay and keep-alive services for improved playback management. - Added playPlaylistFromIndex method to MusicProvider interface for better playlist control. - Implemented track playback from a specified index in both Spotify and YouTube providers. - Enhanced PlaylistView to handle track selection and playback more effectively, including index tracking. - Integrated video ended event handling in YouTubePlayer to automatically advance to the next track. --- .changeset/smooth-music-flow.md | 12 ++ apps/desktop/src/hooks/useCurrentlyPlaying.ts | 15 +- apps/desktop/src/lib/aiClient.ts | 113 +++++------ .../src/lib/playback/autoplayService.ts | 165 +++++++++++++++ apps/desktop/src/lib/playback/index.ts | 4 + .../src/lib/playback/playbackQueueService.ts | 116 +++++++++++ .../src/lib/playback/playbackQueueStore.ts | 89 +++++++++ .../src/lib/playback/spotifyKeepAlive.ts | 188 ++++++++++++++++++ apps/desktop/src/providers/spotify/index.ts | 5 + apps/desktop/src/providers/types.ts | 1 + apps/desktop/src/providers/youtube/index.ts | 18 ++ .../YouTubePlayer/YouTubePlayer.tsx | 10 +- apps/desktop/src/ui/index.tsx | 14 ++ apps/desktop/src/ui/spotifyClient.ts | 46 +++++ apps/desktop/src/ui/views/PlaylistView.tsx | 17 +- 15 files changed, 751 insertions(+), 62 deletions(-) create mode 100644 .changeset/smooth-music-flow.md create mode 100644 apps/desktop/src/lib/playback/autoplayService.ts create mode 100644 apps/desktop/src/lib/playback/index.ts create mode 100644 apps/desktop/src/lib/playback/playbackQueueService.ts create mode 100644 apps/desktop/src/lib/playback/playbackQueueStore.ts create mode 100644 apps/desktop/src/lib/playback/spotifyKeepAlive.ts diff --git a/.changeset/smooth-music-flow.md b/.changeset/smooth-music-flow.md new file mode 100644 index 0000000..54e998d --- /dev/null +++ b/.changeset/smooth-music-flow.md @@ -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 diff --git a/apps/desktop/src/hooks/useCurrentlyPlaying.ts b/apps/desktop/src/hooks/useCurrentlyPlaying.ts index 842a498..1edab9b 100644 --- a/apps/desktop/src/hooks/useCurrentlyPlaying.ts +++ b/apps/desktop/src/hooks/useCurrentlyPlaying.ts @@ -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, @@ -133,7 +135,7 @@ export function useCurrentlyPlaying(pollMs = 3000) { const initialLoadDone = useRef(false); const cachedTrackLoaded = useRef(false); - // Load cached track on mount + // Load cached track on mount and start services useEffect(() => { if (cachedTrackLoaded.current) return; cachedTrackLoaded.current = true; @@ -156,10 +158,21 @@ export function useCurrentlyPlaying(pollMs = 3000) { setState(cacheToPlaybackState(cached)); } } + + // Start playback services + startAutoplayMonitor(); + if (provider === "spotify") { + startKeepAlive(); + } } catch (err) { console.error("Failed to load cached track:", err); } })(); + + return () => { + stopAutoplayMonitor(); + stopKeepAlive(); + }; }, []); // Poll for current playback state diff --git a/apps/desktop/src/lib/aiClient.ts b/apps/desktop/src/lib/aiClient.ts index 8a32422..9879045 100644 --- a/apps/desktop/src/lib/aiClient.ts +++ b/apps/desktop/src/lib/aiClient.ts @@ -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) { diff --git a/apps/desktop/src/lib/playback/autoplayService.ts b/apps/desktop/src/lib/playback/autoplayService.ts new file mode 100644 index 0000000..fabaf8e --- /dev/null +++ b/apps/desktop/src/lib/playback/autoplayService.ts @@ -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 | 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 { + 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 { + 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 { + 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); + } +} + +export function resetAutoplayState(): void { + state.lastProcessedTrackId = null; + state.pendingAutoplayTracks = []; +} diff --git a/apps/desktop/src/lib/playback/index.ts b/apps/desktop/src/lib/playback/index.ts new file mode 100644 index 0000000..07a088f --- /dev/null +++ b/apps/desktop/src/lib/playback/index.ts @@ -0,0 +1,4 @@ +export * from "./autoplayService"; +export * from "./playbackQueueService"; +export * from "./playbackQueueStore"; +export * from "./spotifyKeepAlive"; diff --git a/apps/desktop/src/lib/playback/playbackQueueService.ts b/apps/desktop/src/lib/playback/playbackQueueService.ts new file mode 100644 index 0000000..55881ab --- /dev/null +++ b/apps/desktop/src/lib/playback/playbackQueueService.ts @@ -0,0 +1,116 @@ +import { getActiveProvider, getActiveProviderType } from "../../providers"; +import type { MusicProviderType, UnifiedTrack } from "../../providers/types"; +import { usePlaybackQueueStore } from "./playbackQueueStore"; + +let monitorInterval: ReturnType | null = null; +let lastTrackedVideoId: string | null = null; + +export async function startPlaylistPlayback( + playlistId: string, + tracks: UnifiedTrack[], + startIndex: number +): Promise { + const providerType = await getActiveProviderType(); + const store = usePlaybackQueueStore.getState(); + + store.setPlaylistQueue(playlistId, tracks, startIndex, providerType); + + if (providerType === "youtube") { + const provider = await getActiveProvider(); + const startTrack = tracks[startIndex]; + if (startTrack) { + await provider.playTrack(startTrack.uri); + lastTrackedVideoId = startTrack.id; + startYouTubeQueueMonitor(); + } + } +} + +export async function playSingleTrack(track: UnifiedTrack): Promise { + const providerType = await getActiveProviderType(); + const store = usePlaybackQueueStore.getState(); + const provider = await getActiveProvider(); + + store.setSingleTrack(track, providerType); + await provider.playTrack(track.uri); + + if (providerType === "youtube") { + lastTrackedVideoId = track.id; + } +} + +export function clearPlaybackQueue(): void { + stopYouTubeQueueMonitor(); + usePlaybackQueueStore.getState().clear(); + lastTrackedVideoId = null; +} + +function startYouTubeQueueMonitor(): void { + if (monitorInterval) { + clearInterval(monitorInterval); + } + + monitorInterval = setInterval(async () => { + try { + const store = usePlaybackQueueStore.getState(); + const providerType = await getActiveProviderType(); + + if (providerType !== "youtube") { + stopYouTubeQueueMonitor(); + return; + } + + const provider = await getActiveProvider(); + const playbackState = await provider.getPlaybackState(); + + if (!playbackState) return; + + const { track, isPlaying, progressMs } = playbackState; + const durationMs = track?.durationMs ?? 0; + + const isNearEnd = durationMs > 0 && progressMs >= durationMs - 2000; + const isEnded = !isPlaying && isNearEnd; + + if (isEnded && store.getRemainingCount() > 0) { + const nextTrack = store.advanceToNext(); + if (nextTrack) { + lastTrackedVideoId = nextTrack.id; + await provider.playTrack(nextTrack.uri); + } + } else if (track && track.id !== lastTrackedVideoId) { + lastTrackedVideoId = track.id; + + const trackIndex = store.tracks.findIndex((t) => t.id === track.id); + if (trackIndex !== -1 && trackIndex !== store.currentIndex) { + usePlaybackQueueStore.setState({ currentIndex: trackIndex }); + } + } + } catch (err) { + console.error("YouTube queue monitor error:", err); + } + }, 2000); +} + +function stopYouTubeQueueMonitor(): void { + if (monitorInterval) { + clearInterval(monitorInterval); + monitorInterval = null; + } +} + +export function getPlaybackQueueState(): { + isActive: boolean; + isPlaylistMode: boolean; + currentIndex: number; + totalTracks: number; + provider: MusicProviderType | null; +} { + const store = usePlaybackQueueStore.getState(); + return { + isActive: store.tracks.length > 0, + isPlaylistMode: store.isPlaylistMode, + currentIndex: store.currentIndex, + totalTracks: store.tracks.length, + provider: store.provider, + }; +} diff --git a/apps/desktop/src/lib/playback/playbackQueueStore.ts b/apps/desktop/src/lib/playback/playbackQueueStore.ts new file mode 100644 index 0000000..6c30c5c --- /dev/null +++ b/apps/desktop/src/lib/playback/playbackQueueStore.ts @@ -0,0 +1,89 @@ +import { create } from "zustand"; +import type { MusicProviderType, UnifiedTrack } from "../../providers/types"; + +export interface PlaybackQueueState { + tracks: UnifiedTrack[]; + currentIndex: number; + playlistId: string | null; + provider: MusicProviderType | null; + isPlaylistMode: boolean; + + setPlaylistQueue: ( + playlistId: string, + tracks: UnifiedTrack[], + startIndex: number, + provider: MusicProviderType + ) => void; + setSingleTrack: (track: UnifiedTrack, provider: MusicProviderType) => void; + advanceToNext: () => UnifiedTrack | null; + getCurrentTrack: () => UnifiedTrack | null; + getNextTrack: () => UnifiedTrack | null; + getRemainingCount: () => number; + appendTracks: (tracks: UnifiedTrack[]) => void; + clear: () => void; +} + +export const usePlaybackQueueStore = create((set, get) => ({ + tracks: [], + currentIndex: -1, + playlistId: null, + provider: null, + isPlaylistMode: false, + + setPlaylistQueue: (playlistId, tracks, startIndex, provider) => + set({ + playlistId, + tracks, + currentIndex: startIndex, + provider, + isPlaylistMode: true, + }), + + setSingleTrack: (track, provider) => + set({ + playlistId: null, + tracks: [track], + currentIndex: 0, + provider, + isPlaylistMode: false, + }), + + advanceToNext: () => { + const state = get(); + const nextIndex = state.currentIndex + 1; + if (nextIndex < state.tracks.length) { + set({ currentIndex: nextIndex }); + return state.tracks[nextIndex] ?? null; + } + return null; + }, + + getCurrentTrack: () => { + const state = get(); + return state.tracks[state.currentIndex] ?? null; + }, + + getNextTrack: () => { + const state = get(); + return state.tracks[state.currentIndex + 1] ?? null; + }, + + getRemainingCount: () => { + const state = get(); + return Math.max(0, state.tracks.length - state.currentIndex - 1); + }, + + appendTracks: (tracks) => + set((state) => ({ + tracks: [...state.tracks, ...tracks], + })), + + clear: () => + set({ + tracks: [], + currentIndex: -1, + playlistId: null, + provider: null, + isPlaylistMode: false, + }), +})); diff --git a/apps/desktop/src/lib/playback/spotifyKeepAlive.ts b/apps/desktop/src/lib/playback/spotifyKeepAlive.ts new file mode 100644 index 0000000..67ee4d9 --- /dev/null +++ b/apps/desktop/src/lib/playback/spotifyKeepAlive.ts @@ -0,0 +1,188 @@ +import { getActiveProviderType } from "../../providers"; +import { + getDevices, + getPlayerState, + play as spotifyPlay, + transferPlayback, + type PlayerDevice, +} from "../../ui/spotifyClient"; + +interface KeepAliveState { + enabled: boolean; + lastActiveDeviceId: string | null; + lastSuccessfulPing: number; + consecutiveFailures: number; +} + +const state: KeepAliveState = { + enabled: true, + lastActiveDeviceId: null, + lastSuccessfulPing: Date.now(), + consecutiveFailures: 0, +}; + +const PING_INTERVAL_MS = 120_000; +const MAX_CONSECUTIVE_FAILURES = 3; +const RECOVERY_DELAY_MS = 2000; + +let keepAliveInterval: ReturnType | null = null; + +export function setKeepAliveEnabled(enabled: boolean): void { + state.enabled = enabled; + if (enabled) { + startKeepAlive(); + } else { + stopKeepAlive(); + } +} + +export function isKeepAliveEnabled(): boolean { + return state.enabled; +} + +export function startKeepAlive(): void { + if (keepAliveInterval) return; + + keepAliveInterval = setInterval(async () => { + const providerType = await getActiveProviderType(); + if (providerType !== "spotify" || !state.enabled) return; + + await performKeepAlivePing(); + }, PING_INTERVAL_MS); + + performKeepAlivePing(); +} + +export function stopKeepAlive(): void { + if (keepAliveInterval) { + clearInterval(keepAliveInterval); + keepAliveInterval = null; + } +} + +async function performKeepAlivePing(): Promise { + try { + const playerState = await getPlayerState(); + + if (playerState?.device) { + state.lastActiveDeviceId = playerState.device.id; + state.lastSuccessfulPing = Date.now(); + state.consecutiveFailures = 0; + return; + } + + const devices = await getDevices(); + if (devices.length === 0) { + state.consecutiveFailures++; + return; + } + + const activeDevice = devices.find((d) => d.is_active); + if (activeDevice) { + state.lastActiveDeviceId = activeDevice.id; + state.lastSuccessfulPing = Date.now(); + state.consecutiveFailures = 0; + return; + } + + state.consecutiveFailures++; + + if (state.consecutiveFailures >= MAX_CONSECUTIVE_FAILURES) { + await attemptRecovery(devices); + } + } catch (err) { + state.consecutiveFailures++; + console.warn("Spotify keep-alive ping failed:", err); + + if (state.consecutiveFailures >= MAX_CONSECUTIVE_FAILURES) { + try { + const devices = await getDevices(); + if (devices.length > 0) { + await attemptRecovery(devices); + } + } catch (recoveryErr) { + console.error("Spotify recovery failed:", recoveryErr); + } + } + } +} + +async function attemptRecovery(devices: PlayerDevice[]): Promise { + const targetDevice = + devices.find((d) => d.id === state.lastActiveDeviceId) ?? + devices.find((d) => d.type === "Computer") ?? + devices[0]; + + if (!targetDevice) return; + + try { + await transferPlayback(targetDevice.id, false); + await new Promise((resolve) => setTimeout(resolve, RECOVERY_DELAY_MS)); + + const newState = await getPlayerState(); + if (newState?.device) { + state.lastActiveDeviceId = newState.device.id; + state.lastSuccessfulPing = Date.now(); + state.consecutiveFailures = 0; + } + } catch (err) { + console.error("Failed to transfer playback:", err); + } +} + +export async function ensureActiveDevice(): Promise { + const providerType = await getActiveProviderType(); + if (providerType !== "spotify") return true; + + try { + const playerState = await getPlayerState(); + if (playerState?.device) { + state.lastActiveDeviceId = playerState.device.id; + return true; + } + + const devices = await getDevices(); + if (devices.length === 0) { + return false; + } + + const targetDevice = + devices.find((d) => d.id === state.lastActiveDeviceId) ?? + devices.find((d) => d.type === "Computer") ?? + devices[0]; + + if (!targetDevice) return false; + + await transferPlayback(targetDevice.id, false); + await new Promise((resolve) => setTimeout(resolve, RECOVERY_DELAY_MS)); + + state.lastActiveDeviceId = targetDevice.id; + state.consecutiveFailures = 0; + return true; + } catch (err) { + console.error("Failed to ensure active device:", err); + return false; + } +} + +export async function recoverAndPlay(): Promise { + const hasDevice = await ensureActiveDevice(); + if (!hasDevice) return false; + + try { + spotifyPlay(); + return true; + } catch (err) { + console.error("Failed to resume playback after recovery:", err); + return false; + } +} + +export function getKeepAliveStatus(): { + enabled: boolean; + lastActiveDeviceId: string | null; + lastSuccessfulPing: number; + consecutiveFailures: number; +} { + return { ...state }; +} diff --git a/apps/desktop/src/providers/spotify/index.ts b/apps/desktop/src/providers/spotify/index.ts index 6d94190..e0c282a 100644 --- a/apps/desktop/src/providers/spotify/index.ts +++ b/apps/desktop/src/providers/spotify/index.ts @@ -19,6 +19,7 @@ import { nextTrack as spotifyNextTrack, pause as spotifyPause, play as spotifyPlay, + playPlaylistContext as spotifyPlayPlaylistContext, playTrack as spotifyPlayTrack, previousTrack as spotifyPreviousTrack, searchTracks as spotifySearchTracks, @@ -178,6 +179,10 @@ class SpotifyProviderImpl implements MusicProvider { async addToPlaylist(playlistId: string, trackUri: string): Promise { await addTrackToPlaylist(playlistId, trackUri); } + + async playPlaylistFromIndex(playlistId: string, trackIndex: number): Promise { + await spotifyPlayPlaylistContext(playlistId, trackIndex); + } } let instance: SpotifyProviderImpl | null = null; diff --git a/apps/desktop/src/providers/types.ts b/apps/desktop/src/providers/types.ts index 207ce31..2f2a7e4 100644 --- a/apps/desktop/src/providers/types.ts +++ b/apps/desktop/src/providers/types.ts @@ -101,6 +101,7 @@ export interface MusicProvider { offset: number ): Promise; addToPlaylist(playlistId: string, trackUri: string): Promise; + playPlaylistFromIndex?(playlistId: string, trackIndex: number): Promise; } export interface ProviderAuthState { diff --git a/apps/desktop/src/providers/youtube/index.ts b/apps/desktop/src/providers/youtube/index.ts index 2955082..8a81c3c 100644 --- a/apps/desktop/src/providers/youtube/index.ts +++ b/apps/desktop/src/providers/youtube/index.ts @@ -1,4 +1,5 @@ import { invoke } from "@tauri-apps/api/core"; +import { startPlaylistPlayback } from "../../lib/playback/playbackQueueService"; import type { YouTubePlayerRef } from "../../ui/components/YouTubePlayer"; import type { MusicProvider, @@ -21,6 +22,7 @@ import { let playerRef: YouTubePlayerRef | null = null; let currentTrack: UnifiedTrack | null = null; let recentlyPlayedTracks: UnifiedTrack[] = []; +let cachedPlaylistTracks: Map = new Map(); const MAX_RECENT_TRACKS = 50; export function setYouTubePlayerRef(ref: YouTubePlayerRef | null): void { @@ -263,6 +265,22 @@ class YouTubeProviderImpl implements MusicProvider { const videoId = trackUri.replace("youtube:video:", ""); await addVideoToYouTubePlaylist(playlistId, videoId); } + + async playPlaylistFromIndex(playlistId: string, trackIndex: number): Promise { + let tracks = cachedPlaylistTracks.get(playlistId); + + if (!tracks) { + const result = await this.getPlaylistTracks(playlistId, 200, 0); + tracks = result.tracks; + cachedPlaylistTracks.set(playlistId, tracks); + } + + if (tracks.length === 0) { + throw new Error("Playlist has no tracks"); + } + + await startPlaylistPlayback(playlistId, tracks, trackIndex); + } } export function updateCurrentYouTubeTrack(data: { diff --git a/apps/desktop/src/ui/components/YouTubePlayer/YouTubePlayer.tsx b/apps/desktop/src/ui/components/YouTubePlayer/YouTubePlayer.tsx index f324920..13fd2c2 100644 --- a/apps/desktop/src/ui/components/YouTubePlayer/YouTubePlayer.tsx +++ b/apps/desktop/src/ui/components/YouTubePlayer/YouTubePlayer.tsx @@ -112,6 +112,7 @@ interface YouTubePlayerProps { author: string; duration: number; }) => void; + onVideoEnded?: () => void; playerRef?: React.MutableRefObject; } @@ -150,6 +151,7 @@ export function YouTubePlayer({ onStateChange, onError, onVideoChange, + onVideoEnded, playerRef, }: YouTubePlayerProps) { const containerRef = useRef(null); @@ -163,6 +165,7 @@ export function YouTubePlayer({ const onStateChangeRef = useRef(onStateChange); const onErrorRef = useRef(onError); const onVideoChangeRef = useRef(onVideoChange); + const onVideoEndedRef = useRef(onVideoEnded); const playerRefRef = useRef(playerRef); // Keep refs updated with latest callbacks @@ -171,8 +174,9 @@ export function YouTubePlayer({ onStateChangeRef.current = onStateChange; onErrorRef.current = onError; onVideoChangeRef.current = onVideoChange; + onVideoEndedRef.current = onVideoEnded; playerRefRef.current = playerRef; - }, [onReady, onStateChange, onError, onVideoChange, playerRef]); + }, [onReady, onStateChange, onError, onVideoChange, onVideoEnded, playerRef]); // Initialize player only once useEffect(() => { @@ -231,6 +235,10 @@ export function YouTubePlayer({ isBuffering: state === YT.PlayerState.BUFFERING, }); + if (state === YT.PlayerState.ENDED) { + onVideoEndedRef.current?.(); + } + if (state === YT.PlayerState.PLAYING) { const playerInstance = playerInstanceRef.current; if (playerInstance) { diff --git a/apps/desktop/src/ui/index.tsx b/apps/desktop/src/ui/index.tsx index 104185e..2d96aac 100644 --- a/apps/desktop/src/ui/index.tsx +++ b/apps/desktop/src/ui/index.tsx @@ -354,6 +354,19 @@ export default function App() { updateCurrentYouTubeTrack(data); }; + const handleYouTubeVideoEnded = async () => { + const { usePlaybackQueueStore } = await import("../lib/playback/playbackQueueStore"); + const { getActiveProvider } = await import("../providers"); + + const store = usePlaybackQueueStore.getState(); + const nextTrack = store.advanceToNext(); + + if (nextTrack) { + const provider = await getActiveProvider(); + await provider.playTrack(nextTrack.uri); + } + }; + return (
@@ -374,6 +387,7 @@ export default function App() { playerRef={youtubePlayerRef} onReady={handleYouTubeReady} onVideoChange={handleYouTubeVideoChange} + onVideoEnded={handleYouTubeVideoEnded} />
); diff --git a/apps/desktop/src/ui/spotifyClient.ts b/apps/desktop/src/ui/spotifyClient.ts index 69ac8c6..8d3cc3b 100644 --- a/apps/desktop/src/ui/spotifyClient.ts +++ b/apps/desktop/src/ui/spotifyClient.ts @@ -485,3 +485,49 @@ export async function addTrackToPlaylist(playlistId: string, trackUri: string): body: JSON.stringify({ uris: [trackUri] }), }); } + +export async function playPlaylistContext( + playlistId: string, + offset: number +): Promise { + await request("https://api.spotify.com/v1/me/player/play", { + method: "PUT", + body: JSON.stringify({ + context_uri: `spotify:playlist:${playlistId}`, + offset: { position: offset }, + }), + }); +} + +export async function playAlbumContext(albumId: string, offset: number): Promise { + await request("https://api.spotify.com/v1/me/player/play", { + method: "PUT", + body: JSON.stringify({ + context_uri: `spotify:album:${albumId}`, + offset: { position: offset }, + }), + }); +} + +interface DevicesResponse { + devices: PlayerDevice[]; +} + +export async function getDevices(): Promise { + const data = await request("https://api.spotify.com/v1/me/player/devices"); + return data.devices ?? []; +} + +export async function transferPlayback(deviceId: string, play: boolean): Promise { + await request("https://api.spotify.com/v1/me/player", { + method: "PUT", + body: JSON.stringify({ device_ids: [deviceId], play }), + }); +} + +export async function getQueue(): Promise<{ currently_playing: SimplifiedTrack | null; queue: SimplifiedTrack[] }> { + const data = await request<{ currently_playing: SimplifiedTrack | null; queue: SimplifiedTrack[] }>( + "https://api.spotify.com/v1/me/player/queue" + ); + return data; +} \ No newline at end of file diff --git a/apps/desktop/src/ui/views/PlaylistView.tsx b/apps/desktop/src/ui/views/PlaylistView.tsx index 334281b..1ce220b 100644 --- a/apps/desktop/src/ui/views/PlaylistView.tsx +++ b/apps/desktop/src/ui/views/PlaylistView.tsx @@ -150,11 +150,20 @@ export default function PlaylistView({ onBack }: PlaylistViewProps) { loadedCountRef.current = 0; }, []); - const handlePlayTrack = async (track: UnifiedTrack) => { + const handlePlayTrack = async (track: UnifiedTrack, trackIndex: number) => { setPlayingId(track.id); try { const provider = await getActiveProvider(); - await provider.playTrack(track.uri); + const type = await getActiveProviderType(); + + if (selectedPlaylist && provider.playPlaylistFromIndex) { + await provider.playPlaylistFromIndex(selectedPlaylist.id, trackIndex); + } else if (type === "youtube" && selectedPlaylist) { + const { startPlaylistPlayback } = await import("../../lib/playback/playbackQueueService"); + await startPlaylistPlayback(selectedPlaylist.id, tracks, trackIndex); + } else { + await provider.playTrack(track.uri); + } } catch (err) { console.error("Play failed:", err); } finally { @@ -341,7 +350,7 @@ export default function PlaylistView({ onBack }: PlaylistViewProps) { className="h-full overflow-auto" >
    - {tracks.map((track) => { + {tracks.map((track, index) => { const albumArt = track.album.images[0]?.url; const artistNames = track.artists.map((a) => a.name).join(", "); const isPlaying = playingId === track.id; @@ -350,7 +359,7 @@ export default function PlaylistView({ onBack }: PlaylistViewProps) {