849 lines
31 KiB
Python
849 lines
31 KiB
Python
"""Music player service using VLC Python bindings with playlist management and real-time sync."""
|
|
|
|
import os
|
|
import threading
|
|
import time
|
|
from datetime import datetime
|
|
from typing import Any, Optional
|
|
from zoneinfo import ZoneInfo
|
|
|
|
import vlc
|
|
from flask import current_app, request
|
|
|
|
from app.models.playlist import Playlist
|
|
from app.models.sound import Sound
|
|
from app.models.sound_played import SoundPlayed
|
|
from app.services.logging_service import LoggingService
|
|
from app.services.socketio_service import socketio_service
|
|
|
|
logger = LoggingService.get_logger(__name__)
|
|
|
|
# Constants
|
|
TRACK_START_THRESHOLD_MS = 500 # 500 milliseconds - threshold for considering a track as "starting fresh"
|
|
STATE_CHANGE_THRESHOLD_MS = (
|
|
1000 # 1 second threshold for state change detection
|
|
)
|
|
PLAY_COMPLETION_THRESHOLD = 0.20 # 20% completion threshold to count as a play
|
|
|
|
|
|
class MusicPlayerService:
|
|
"""Service for managing a VLC music player with playlist support."""
|
|
|
|
def __init__(self):
|
|
"""Initialize the music player service."""
|
|
self.instance: Optional[vlc.Instance] = None
|
|
self.player: Optional[vlc.MediaPlayer] = None
|
|
self.app: Optional[Any] = None # Store Flask app instance for context
|
|
|
|
self.current_playlist_id: Optional[int] = None
|
|
self.current_track_index = 0
|
|
self.playlist_files: list[str] = (
|
|
[]
|
|
) # Store file paths for manual playlist management
|
|
self.volume = 80
|
|
self.play_mode = (
|
|
"continuous" # single, continuous, loop-playlist, loop-one, random
|
|
)
|
|
self.is_playing = False
|
|
self.current_time = 0
|
|
self.duration = 0
|
|
self.last_sync_time = 0
|
|
self.sync_interval = (
|
|
0.5 # seconds (increased frequency to catch track endings)
|
|
)
|
|
self.lock = threading.Lock()
|
|
self._sync_thread = None
|
|
self._stop_sync = False
|
|
self._track_ending_handled = (
|
|
False # Flag to prevent duplicate ending triggers
|
|
)
|
|
self._track_play_tracked = (
|
|
False # Flag to track if current track play has been logged
|
|
)
|
|
self._cumulative_play_time = (
|
|
0 # Cumulative time actually played for current track
|
|
)
|
|
self._last_position_update = (
|
|
0 # Last position for calculating continuous play time
|
|
)
|
|
|
|
def start_vlc_instance(self) -> bool:
|
|
"""Start a VLC instance with Python bindings."""
|
|
try:
|
|
# Create VLC instance with audio output enabled
|
|
vlc_args = [
|
|
"--intf=dummy", # No interface
|
|
"--no-video", # Audio only
|
|
]
|
|
|
|
self.instance = vlc.Instance(vlc_args)
|
|
if not self.instance:
|
|
logger.error("Failed to create VLC instance")
|
|
return False
|
|
|
|
# Create media player
|
|
self.player = self.instance.media_player_new()
|
|
if not self.player:
|
|
logger.error("Failed to create VLC media player")
|
|
return False
|
|
|
|
# Set initial volume
|
|
self.player.audio_set_volume(self.volume)
|
|
|
|
logger.info("VLC music player started successfully")
|
|
|
|
# Automatically load the current playlist
|
|
self._load_current_playlist_on_startup()
|
|
|
|
self._start_sync_thread()
|
|
return True
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error starting VLC instance: {e}")
|
|
return False
|
|
|
|
def stop_vlc_instance(self) -> bool:
|
|
"""Stop the VLC instance."""
|
|
try:
|
|
self._stop_sync = True
|
|
if self._sync_thread:
|
|
self._sync_thread.join(timeout=2)
|
|
|
|
if self.player:
|
|
self.player.stop()
|
|
|
|
# Release VLC objects
|
|
self.player = None
|
|
self.instance = None
|
|
|
|
logger.info("VLC music player stopped")
|
|
return True
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error stopping VLC instance: {e}")
|
|
return False
|
|
|
|
def load_playlist(self, playlist_id: int, reload: bool = False) -> bool:
|
|
"""Load a playlist into VLC."""
|
|
try:
|
|
if not self.instance or not self.player:
|
|
logger.error("VLC not initialized")
|
|
return False
|
|
|
|
with self.lock:
|
|
# Ensure we have Flask app context for database queries
|
|
if current_app:
|
|
with current_app.app_context():
|
|
playlist = Playlist.query.get(playlist_id)
|
|
if not playlist:
|
|
return False
|
|
|
|
return self._load_playlist_with_context(
|
|
playlist, reload
|
|
)
|
|
else:
|
|
# Fallback for when no Flask context is available
|
|
logger.warning(
|
|
"No Flask context available for loading playlist"
|
|
)
|
|
return False
|
|
except Exception as e:
|
|
logger.error(f"Error loading playlist {playlist_id}: {e}")
|
|
return False
|
|
|
|
def _build_thumbnail_url(
|
|
self, sound_type: str, thumbnail_filename: str
|
|
) -> str:
|
|
"""Build absolute thumbnail URL."""
|
|
try:
|
|
# Try to get base URL from current request context
|
|
if request:
|
|
base_url = request.url_root.rstrip("/")
|
|
else:
|
|
# Fallback to localhost if no request context
|
|
base_url = "http://localhost:5000"
|
|
return f"{base_url}/api/sounds/{sound_type.lower()}/thumbnails/{thumbnail_filename}"
|
|
except Exception:
|
|
# Fallback if request context is not available
|
|
return f"http://localhost:5000/api/sounds/{sound_type.lower()}/thumbnails/{thumbnail_filename}"
|
|
|
|
def _load_playlist_with_context(
|
|
self, playlist, reload: bool = False
|
|
) -> bool:
|
|
"""Load playlist with database context already established."""
|
|
try:
|
|
# Clear current playlist
|
|
self.playlist_files = []
|
|
|
|
# Add tracks to our internal playlist
|
|
for playlist_sound in sorted(
|
|
playlist.playlist_sounds, key=lambda x: x.order
|
|
):
|
|
sound = playlist_sound.sound
|
|
if sound:
|
|
file_path = self._get_sound_file_path(sound)
|
|
if file_path and os.path.exists(file_path):
|
|
self.playlist_files.append(file_path)
|
|
|
|
deleted = False
|
|
if reload:
|
|
# Set current track index to the real index of the current track
|
|
# in case the order has changed or the track has been deleted
|
|
current_track = self.get_current_track()
|
|
current_track_id = (
|
|
current_track["id"] if current_track else None
|
|
)
|
|
sound_ids = [
|
|
ps.sound.id
|
|
for ps in sorted(
|
|
playlist.playlist_sounds, key=lambda x: x.order
|
|
)
|
|
]
|
|
if current_track_id in sound_ids:
|
|
self.current_track_index = sound_ids.index(current_track_id)
|
|
else:
|
|
deleted = True
|
|
|
|
if not reload or deleted:
|
|
self.current_playlist_id = playlist.id
|
|
self.current_track_index = 0
|
|
|
|
# Load first track if available
|
|
if self.playlist_files:
|
|
self._load_track_at_index(0)
|
|
|
|
# Emit playlist loaded event
|
|
self._emit_player_state()
|
|
|
|
logger.info(
|
|
f"Loaded playlist '{playlist.name}' with {len(self.playlist_files)} tracks"
|
|
)
|
|
return True
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error in _load_playlist_with_context: {e}")
|
|
return False
|
|
|
|
def _load_track_at_index(self, index: int) -> bool:
|
|
"""Load a specific track by index."""
|
|
try:
|
|
if 0 <= index < len(self.playlist_files):
|
|
file_path = self.playlist_files[index]
|
|
media = self.instance.media_new(file_path)
|
|
if media:
|
|
self.player.set_media(media)
|
|
self.current_track_index = index
|
|
# Reset track ending flag when loading a new track
|
|
self._track_ending_handled = False
|
|
self._track_play_tracked = (
|
|
False # Reset play tracking for new track
|
|
)
|
|
# Reset cumulative play time tracking for new track
|
|
self._cumulative_play_time = 0
|
|
self._last_position_update = 0
|
|
return True
|
|
return False
|
|
except Exception as e:
|
|
logger.error(f"Error loading track at index {index}: {e}")
|
|
return False
|
|
|
|
def _track_sound_play(self, sound_id: int) -> None:
|
|
"""Track that a sound has been played."""
|
|
try:
|
|
# Use stored app instance or current_app
|
|
app_to_use = self.app or current_app
|
|
if app_to_use:
|
|
with app_to_use.app_context():
|
|
# Get the sound and increment its play count
|
|
sound = Sound.query.get(sound_id)
|
|
if sound:
|
|
sound.play_count += 1
|
|
sound.updated_at = datetime.now(tz=ZoneInfo("UTC"))
|
|
logger.info(
|
|
f"Incremented play count for sound '{sound.name}' (ID: {sound_id})"
|
|
)
|
|
|
|
# Create a sound played record without user_id (anonymous play)
|
|
SoundPlayed.create_play_record(
|
|
user_id=None, sound_id=sound_id, commit=True
|
|
)
|
|
logger.info(
|
|
f"Created anonymous play record for sound ID: {sound_id}"
|
|
)
|
|
except Exception as e:
|
|
logger.error(f"Error tracking sound play for sound {sound_id}: {e}")
|
|
|
|
def _get_sound_file_path(self, sound: Sound) -> Optional[str]:
|
|
"""Get the file path for a sound, preferring normalized version."""
|
|
try:
|
|
base_path = "sounds/stream"
|
|
base_normalized_path = "sounds/normalized/stream"
|
|
|
|
# Check for normalized version first
|
|
if sound.is_normalized and sound.normalized_filename:
|
|
normalized_path = os.path.join(
|
|
base_normalized_path,
|
|
sound.normalized_filename,
|
|
)
|
|
if os.path.exists(normalized_path):
|
|
return os.path.abspath(normalized_path)
|
|
|
|
# Fall back to original file
|
|
original_path = os.path.join(base_path, sound.filename)
|
|
if os.path.exists(original_path):
|
|
return os.path.abspath(original_path)
|
|
|
|
return None
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error getting file path for sound {sound.id}: {e}")
|
|
return None
|
|
|
|
def play(self) -> bool:
|
|
"""Start playback."""
|
|
try:
|
|
if not self.player:
|
|
return False
|
|
|
|
# Reset track ending flag when starting playback
|
|
self._track_ending_handled = False
|
|
|
|
result = self.player.play()
|
|
if result == 0: # Success
|
|
self.is_playing = True
|
|
self._track_play_tracked = (
|
|
False # Track when we first start playing
|
|
)
|
|
self._emit_player_state()
|
|
return True
|
|
return False
|
|
except Exception as e:
|
|
logger.error(f"Error starting playback: {e}")
|
|
return False
|
|
|
|
def pause(self) -> bool:
|
|
"""Pause playback."""
|
|
try:
|
|
if not self.player:
|
|
return False
|
|
|
|
self.player.pause()
|
|
self.is_playing = False
|
|
self._emit_player_state()
|
|
return True
|
|
except Exception as e:
|
|
logger.error(f"Error pausing playback: {e}")
|
|
return False
|
|
|
|
def stop(self) -> bool:
|
|
"""Stop playback."""
|
|
try:
|
|
if not self.player:
|
|
return False
|
|
|
|
self.player.stop()
|
|
self.is_playing = False
|
|
self.current_time = 0
|
|
self._emit_player_state()
|
|
return True
|
|
except Exception as e:
|
|
logger.error(f"Error stopping playback: {e}")
|
|
return False
|
|
|
|
def next_track(self, force_play: bool = False) -> bool:
|
|
"""Skip to next track."""
|
|
try:
|
|
if not self.playlist_files:
|
|
return False
|
|
|
|
next_index = self.current_track_index + 1
|
|
|
|
# Handle different play modes
|
|
if self.play_mode == "loop-playlist" and next_index >= len(
|
|
self.playlist_files
|
|
):
|
|
next_index = 0
|
|
elif self.play_mode == "random":
|
|
import random
|
|
|
|
next_index = random.randint(0, len(self.playlist_files) - 1)
|
|
elif next_index >= len(self.playlist_files):
|
|
# End of playlist in continuous mode
|
|
self.stop()
|
|
return True
|
|
|
|
if self._load_track_at_index(next_index):
|
|
if self.is_playing or force_play:
|
|
self.play()
|
|
self._emit_player_state()
|
|
return True
|
|
return False
|
|
except Exception as e:
|
|
logger.error(f"Error skipping to next track: {e}")
|
|
return False
|
|
|
|
def previous_track(self) -> bool:
|
|
"""Skip to previous track."""
|
|
try:
|
|
if not self.playlist_files:
|
|
return False
|
|
|
|
prev_index = self.current_track_index - 1
|
|
|
|
# Handle different play modes
|
|
if self.play_mode == "loop-playlist" and prev_index < 0:
|
|
prev_index = len(self.playlist_files) - 1
|
|
elif self.play_mode == "random":
|
|
import random
|
|
|
|
prev_index = random.randint(0, len(self.playlist_files) - 1)
|
|
elif prev_index < 0:
|
|
prev_index = 0
|
|
|
|
if self._load_track_at_index(prev_index):
|
|
if self.is_playing:
|
|
self.play()
|
|
self._emit_player_state()
|
|
return True
|
|
return False
|
|
except Exception as e:
|
|
logger.error(f"Error skipping to previous track: {e}")
|
|
return False
|
|
|
|
def seek(self, position: float) -> bool:
|
|
"""Seek to position (0.0 to 1.0)."""
|
|
try:
|
|
if not self.player:
|
|
return False
|
|
|
|
# Set position as percentage
|
|
self.player.set_position(position)
|
|
self.current_time = position * self.duration
|
|
self._emit_player_state()
|
|
return True
|
|
except Exception as e:
|
|
logger.error(f"Error seeking: {e}")
|
|
return False
|
|
|
|
def set_volume(self, volume: int) -> bool:
|
|
"""Set volume (0-100)."""
|
|
try:
|
|
if not self.player:
|
|
return False
|
|
|
|
volume = max(0, min(100, volume))
|
|
result = self.player.audio_set_volume(volume)
|
|
if result == 0: # Success
|
|
self.volume = volume
|
|
self._emit_player_state()
|
|
return True
|
|
return False
|
|
except Exception as e:
|
|
logger.error(f"Error setting volume: {e}")
|
|
return False
|
|
|
|
def set_play_mode(self, mode: str) -> bool:
|
|
"""Set play mode."""
|
|
try:
|
|
if mode in [
|
|
"continuous",
|
|
"loop-playlist",
|
|
"loop-one",
|
|
"random",
|
|
"single",
|
|
]:
|
|
self.play_mode = mode
|
|
self._emit_player_state()
|
|
return True
|
|
return False
|
|
except Exception as e:
|
|
logger.error(f"Error setting play mode: {e}")
|
|
return False
|
|
|
|
def play_track_at_index(self, index: int) -> bool:
|
|
"""Play track at specific playlist index."""
|
|
try:
|
|
if self._load_track_at_index(index):
|
|
result = self.play()
|
|
self._emit_player_state()
|
|
return result
|
|
return False
|
|
except Exception as e:
|
|
logger.error(f"Error playing track at index {index}: {e}")
|
|
return False
|
|
|
|
def _get_playlist_length(self) -> int:
|
|
"""Get current playlist length."""
|
|
return len(self.playlist_files)
|
|
|
|
def get_current_track(self) -> Optional[dict]:
|
|
"""Get current track information."""
|
|
try:
|
|
if not self.current_playlist_id:
|
|
return None
|
|
|
|
# Use stored app instance or current_app
|
|
app_to_use = self.app or current_app
|
|
if app_to_use:
|
|
with app_to_use.app_context():
|
|
playlist = Playlist.query.get(self.current_playlist_id)
|
|
if playlist and 0 <= self.current_track_index < len(
|
|
playlist.playlist_sounds
|
|
):
|
|
playlist_sounds = sorted(
|
|
playlist.playlist_sounds, key=lambda x: x.order
|
|
)
|
|
current_playlist_sound = playlist_sounds[
|
|
self.current_track_index
|
|
]
|
|
sound = current_playlist_sound.sound
|
|
|
|
if sound:
|
|
return {
|
|
"id": sound.id,
|
|
"title": sound.name,
|
|
"artist": None, # Could be extracted from metadata
|
|
"duration": sound.duration or 0,
|
|
"thumbnail": (
|
|
self._build_thumbnail_url(
|
|
sound.type, sound.thumbnail
|
|
)
|
|
if sound.thumbnail
|
|
else None
|
|
),
|
|
"type": sound.type,
|
|
}
|
|
return None
|
|
except Exception as e:
|
|
logger.error(f"Error getting current track: {e}")
|
|
return None
|
|
|
|
def get_playlist_tracks(self) -> list[dict]:
|
|
"""Get all tracks in current playlist."""
|
|
try:
|
|
tracks = []
|
|
if not self.current_playlist_id:
|
|
return tracks
|
|
|
|
# Ensure we have Flask app context
|
|
if current_app:
|
|
with current_app.app_context():
|
|
playlist = Playlist.query.get(self.current_playlist_id)
|
|
if playlist:
|
|
for playlist_sound in sorted(
|
|
playlist.playlist_sounds, key=lambda x: x.order
|
|
):
|
|
sound = playlist_sound.sound
|
|
if sound:
|
|
tracks.append(
|
|
{
|
|
"id": sound.id,
|
|
"title": sound.name,
|
|
"artist": None,
|
|
"duration": sound.duration or 0,
|
|
"thumbnail": (
|
|
self._build_thumbnail_url(
|
|
sound.type, sound.thumbnail
|
|
)
|
|
if sound.thumbnail
|
|
else None
|
|
),
|
|
"type": sound.type,
|
|
}
|
|
)
|
|
return tracks
|
|
except Exception as e:
|
|
logger.error(f"Error getting playlist tracks: {e}")
|
|
return []
|
|
|
|
def get_player_state(self) -> dict[str, Any]:
|
|
"""Get complete player state."""
|
|
current_track = self.get_current_track()
|
|
return {
|
|
"is_playing": self.is_playing,
|
|
"current_time": self.current_time,
|
|
"duration": self.duration,
|
|
"volume": self.volume,
|
|
"play_mode": self.play_mode,
|
|
"current_track": current_track,
|
|
"current_track_id": current_track["id"] if current_track else None,
|
|
"current_track_index": self.current_track_index,
|
|
"playlist": self.get_playlist_tracks(),
|
|
"playlist_id": self.current_playlist_id,
|
|
}
|
|
|
|
def _start_sync_thread(self):
|
|
"""Start background thread to sync with VLC state."""
|
|
self._stop_sync = False
|
|
self._sync_thread = threading.Thread(
|
|
target=self._sync_loop, daemon=True
|
|
)
|
|
self._sync_thread.start()
|
|
|
|
def _sync_loop(self):
|
|
"""Background loop to sync player state with VLC."""
|
|
while not self._stop_sync:
|
|
try:
|
|
current_time = time.time()
|
|
if current_time - self.last_sync_time >= self.sync_interval:
|
|
self._sync_with_vlc()
|
|
self.last_sync_time = current_time
|
|
|
|
time.sleep(0.1) # Small sleep to prevent busy waiting
|
|
|
|
except Exception as e:
|
|
logger.debug(f"Error in sync loop: {e}")
|
|
time.sleep(1) # Longer sleep on error
|
|
|
|
def _sync_with_vlc(self):
|
|
"""Sync internal state with VLC."""
|
|
try:
|
|
if not self.player:
|
|
return
|
|
|
|
# Update playback state
|
|
old_playing = self.is_playing
|
|
old_time = self.current_time
|
|
|
|
# Get current state from VLC
|
|
state = self.player.get_state()
|
|
self.is_playing = state == vlc.State.Playing
|
|
|
|
# Get time and duration (in milliseconds)
|
|
self.current_time = self.player.get_time()
|
|
self.duration = self.player.get_length()
|
|
|
|
# Get volume
|
|
self.volume = self.player.audio_get_volume()
|
|
|
|
# Enhanced track ending detection
|
|
track_ended = False
|
|
|
|
# Check for ended state
|
|
if state == vlc.State.Ended:
|
|
track_ended = True
|
|
logger.info(
|
|
f"Track ended via VLC State.Ended, mode: {self.play_mode}"
|
|
)
|
|
|
|
# Also check if we're very close to the end (within 500ms) and not playing
|
|
elif (
|
|
self.duration > 0
|
|
and self.current_time > 0
|
|
and self.current_time >= (self.duration - 500)
|
|
and not self.is_playing
|
|
and old_playing
|
|
):
|
|
track_ended = True
|
|
logger.info(
|
|
f"Track ended via time check, mode: {self.play_mode}"
|
|
)
|
|
|
|
# Handle track ending based on play mode (only if not already handled)
|
|
if track_ended and not self._track_ending_handled:
|
|
self._track_ending_handled = True
|
|
|
|
if self.play_mode == "loop-one":
|
|
logger.info("Restarting track for loop-one mode")
|
|
self.play_track_at_index(self.current_track_index)
|
|
elif self.play_mode == "single":
|
|
logger.info(
|
|
"Track ended in single mode - stopping playback"
|
|
)
|
|
self.stop()
|
|
elif self.play_mode in [
|
|
"continuous",
|
|
"loop-playlist",
|
|
"random",
|
|
]:
|
|
logger.info(
|
|
f"Advancing to next track for {self.play_mode} mode"
|
|
)
|
|
self.next_track(True)
|
|
|
|
# Reset the flag after track change
|
|
self._track_ending_handled = False
|
|
|
|
# Reset the flag if we're playing again (new track started)
|
|
elif self.is_playing and not old_playing:
|
|
self._track_ending_handled = False
|
|
|
|
# Update cumulative play time for continuous listening tracking
|
|
if self.is_playing and old_playing and self.current_time > 0:
|
|
# Calculate time elapsed since last update (but cap it to prevent huge jumps from seeking)
|
|
if self._last_position_update > 0:
|
|
time_diff = self.current_time - self._last_position_update
|
|
# Only add time if it's a reasonable progression (not a big jump from seeking)
|
|
if (
|
|
0 <= time_diff <= (self.sync_interval * 1000 * 2)
|
|
): # Max 2x sync interval
|
|
self._cumulative_play_time += time_diff
|
|
|
|
self._last_position_update = self.current_time
|
|
elif self.is_playing and not old_playing:
|
|
# Just started playing, initialize position tracking
|
|
self._last_position_update = (
|
|
self.current_time if self.current_time > 0 else 0
|
|
)
|
|
|
|
# Track play event when cumulative listening reaches 20% of track duration
|
|
if (
|
|
self.is_playing
|
|
and not self._track_play_tracked
|
|
and self.duration > 0
|
|
and self._cumulative_play_time
|
|
>= (self.duration * PLAY_COMPLETION_THRESHOLD)
|
|
):
|
|
current_track = self.get_current_track()
|
|
if current_track:
|
|
self._track_sound_play(current_track["id"])
|
|
self._track_play_tracked = True
|
|
logger.info(
|
|
f"Tracked play for '{current_track['title']}' after {self._cumulative_play_time}ms "
|
|
f"cumulative listening ({(self._cumulative_play_time/self.duration)*100:.1f}% of track)"
|
|
)
|
|
|
|
# Emit updates if state changed significantly or periodically
|
|
state_changed = (
|
|
old_playing != self.is_playing
|
|
or abs(old_time - self.current_time)
|
|
> STATE_CHANGE_THRESHOLD_MS # More than 1 second difference
|
|
)
|
|
|
|
# Always emit if playing to keep frontend updated
|
|
if state_changed or self.is_playing:
|
|
self._emit_player_state()
|
|
|
|
except Exception as e:
|
|
logger.debug(f"Error syncing with VLC: {e}")
|
|
|
|
def _emit_player_state(self):
|
|
"""Emit current player state via SocketIO."""
|
|
try:
|
|
# Update state from VLC before emitting
|
|
self._sync_vlc_state_only()
|
|
|
|
# Try to use Flask context for database queries
|
|
app_to_use = self.app or current_app
|
|
|
|
if app_to_use:
|
|
with app_to_use.app_context():
|
|
state = self.get_player_state()
|
|
socketio_service.emit_to_all("player_state_update", state)
|
|
logger.info(
|
|
f"Emitted player state: playing={state['is_playing']}, time={state['current_time']}, track={state.get('current_track', {}).get('title', 'None')}"
|
|
)
|
|
else:
|
|
# Fallback when no Flask context - emit basic state without database queries
|
|
basic_state = {
|
|
"is_playing": self.is_playing,
|
|
"current_time": self.current_time,
|
|
"duration": self.duration,
|
|
"volume": self.volume,
|
|
"play_mode": self.play_mode,
|
|
"current_track": None,
|
|
"current_track_id": None,
|
|
"current_track_index": self.current_track_index,
|
|
"playlist": [],
|
|
"playlist_id": self.current_playlist_id,
|
|
}
|
|
socketio_service.emit_to_all("player_state_update", basic_state)
|
|
logger.info(
|
|
f"Emitted basic player state: playing={basic_state['is_playing']}, time={basic_state['current_time']}"
|
|
)
|
|
except Exception as e:
|
|
logger.debug(f"Error emitting player state: {e}")
|
|
|
|
def _sync_vlc_state_only(self):
|
|
"""Sync only the VLC state without auto-advance logic."""
|
|
try:
|
|
if not self.player:
|
|
return
|
|
|
|
# Get current state from VLC
|
|
state = self.player.get_state()
|
|
self.is_playing = state == vlc.State.Playing
|
|
|
|
# Get time and duration (in milliseconds)
|
|
self.current_time = self.player.get_time()
|
|
self.duration = self.player.get_length()
|
|
|
|
# Get volume
|
|
self.volume = self.player.audio_get_volume()
|
|
|
|
except Exception as e:
|
|
logger.debug(f"Error syncing VLC state: {e}")
|
|
|
|
def _load_current_playlist_on_startup(self):
|
|
"""Load the current playlist automatically on startup."""
|
|
try:
|
|
if not self.app:
|
|
logger.warning(
|
|
"No Flask app context available, skipping current playlist load"
|
|
)
|
|
return
|
|
|
|
with self.app.app_context():
|
|
# Find the current playlist
|
|
current_playlist = Playlist.find_current_playlist()
|
|
|
|
if current_playlist:
|
|
success = self.load_playlist(current_playlist.id)
|
|
if success:
|
|
logger.info(
|
|
f"Automatically loaded current playlist '{current_playlist.name}' with {len(self.playlist_files)} tracks"
|
|
)
|
|
else:
|
|
logger.warning(
|
|
"Failed to load current playlist on startup"
|
|
)
|
|
else:
|
|
logger.info("No current playlist found to load on startup")
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error loading current playlist on startup: {e}")
|
|
|
|
def reload_current_playlist_if_modified(
|
|
self, modified_playlist_id: int
|
|
) -> bool:
|
|
"""Reload the current playlist if it's the one that was modified."""
|
|
try:
|
|
if not self.app:
|
|
logger.warning(
|
|
"No Flask app context available, skipping playlist reload"
|
|
)
|
|
return False
|
|
|
|
with self.app.app_context():
|
|
# Find the current playlist
|
|
current_playlist = Playlist.find_current_playlist()
|
|
|
|
if (
|
|
current_playlist
|
|
and current_playlist.id == modified_playlist_id
|
|
):
|
|
# Reload the playlist
|
|
success = self.load_playlist(current_playlist.id, True)
|
|
|
|
if success:
|
|
logger.info(
|
|
f"Reloaded current playlist '{current_playlist.name}' after modification"
|
|
)
|
|
return True
|
|
else:
|
|
logger.warning(
|
|
"Failed to reload current playlist after modification"
|
|
)
|
|
return False
|
|
else:
|
|
# Not the current playlist, no need to reload
|
|
return True
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error reloading current playlist: {e}")
|
|
return False
|
|
|
|
|
|
# Global music player service instance
|
|
music_player_service = MusicPlayerService()
|