找回密码
立即注册
搜索
热搜: Java Python Linux Go
发回帖 发新帖

5282

积分

0

好友

719

主题
发表于 5 小时前 | 查看: 5| 回复: 0

去做,去错,去破!

一个极客的深夜实验:记 FletMusic 的诞生

最近经常捣鼓 AI 辅助编程,攒了不少实战经验。如果这个项目能激起大家的共鸣,后续我考虑专门写一篇面向非程序员背景的朋友,如何高效驾驭 AI Coding 的标准作业流程。  

受 YesPlayMusic 的启发,我一直想做个反应更快、更轻量的桌面播放器。趁着某天夜深人静,我翻出了开源音乐 API 项目 pyncm,配合 python flet 搞 UI、pygame-ce 处理音频,最终捣鼓出了这款能畅听整首歌曲的极简播放器。

声明:本项目仅用于技术学习、研究及交流。所有代码均由 AI 生成,旨在演示 AI 辅助开发的可能性。严禁用于商业用途、非法数据爬取或绕过授权限制。使用者需自行确保符合相关法律法规及第三方平台协议,因使用本项目产生的任何责任均由使用者承担。

视觉效果速览

以下是播放器的实际运行界面,极简设计,打开即搜即听。

FletMusic 主界面推荐歌单与播放栏

点进歌单后,整张列表清晰展开,支持查看歌名、艺术家和时长,播放状态一目了然。

FletMusic 歌单详情页展示歌曲列表

AI 编程实战:零基础也能驾驭的 SOP

对于非技术背景的朋友,在真正进入最后的“代码优化”阶段之前,难免会经历一段陡峭的学习曲线。这里分享几条避坑指南:

  1. 拒绝重复造轮子 - 除非市面上的确找不到现成方案,否则千万别为了写代码而写代码。

    典型的反面教材是:放着别人打磨了几十年的成熟设计和交互不学,偏要闷头闭门造车。最后折腾出来的东西难用得不行,自己还不得不硬着头皮用下去。

  2. 先想好再动手 - 扎实的高层设计才是项目成败的基石。
  3. 死磕核心功能 - 用第一性原理拆解需求,优先借助 AI 把最关键的路径跑通。
  4. 毫秒级吃透日志 - 想要高效调试、摸清程序的内在逻辑,务必开启最详尽的日志记录。
  5. 逐步叠加功能 - 这是最磨人的阶段。此时代码体系仍不完整,缺乏足够的正确性去压制 AI 的随机 “幻想”,需要频繁的人工校准。
  6. 收官阶段的代码瘦身 - 等到全部功能与体验都到位后,再让 AI 依据 KISS、DRY、OOP 等原则彻底重构冗余代码。在这一环节,AI 的表现往往会让你啧啧称奇。

开箱即用的技术组件

  • UI 框架: Flet
  • 音频播放: pygame-ce
  • API 调用: pyncm
  • 打包工具: PyInstaller

核心源码放送

主控逻辑:fletmusic.py

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
FletMusic - 极简音乐播放器
"""

import time
import asyncio
import os
import tempfile
import urllib.request
from typing import Optional, Dict, List, Any, Callable, Awaitable
from dataclasses import dataclass
from functools import wraps
from enum import Enum
import flet as ft
from pyncm import apis
from pyncm.apis import cloudsearch
import pygame

class ViewType(Enum):
    PLAYLISTS = "playlists"
    SONGS = "songs"

@dataclass
class UIConfig:
    GRID_RUNS_COUNT: int = 2
    GRID_MAX_EXTENT: int = 240
    GRID_SPACING: int = 12
    COVER_SIZE_LARGE: int = 180
    COVER_SIZE_SMALL: int = 64
    COVER_SIZE_LIST: int = 56
    DEFAULT_KEYWORD: str = "华语经典"
    CACHE_EXPIRE_TIME: int = 3600
    DEFAULT_LIMIT: int = 50

BLACK = ft.Colors.BLACK
WHITE = ft.Colors.WHITE
GRAY = ft.Colors.GREY_400
LIGHT_GRAY = ft.Colors.GREY_200
PRIMARY = ft.Colors.BLUE_500

@dataclass
class CacheEntry:
    value: Any
    expire_time: float

def cached(expire_time: int = UIConfig.CACHE_EXPIRE_TIME):
    def decorator(func: Callable[..., Awaitable[Any]]):
        @wraps(func)
        async def wrapper(self, *args, **kwargs):
            cache_key = f"{func.__name__}:{str(args)}:{str(kwargs)}"

            if not hasattr(self, '_cache'):
                self._cache = {}

            if cache_key in self._cache:
                entry = self._cache[cache_key]
                if entry.expire_time > time.time():
                    return entry.value

            result = await func(self, *args, **kwargs)
            self._cache[cache_key] = CacheEntry(
                value=result,
                expire_time=time.time() + expire_time
            )
            return result
        return wrapper
    return decorator

def format_duration(duration_ms: int) -> str:
    if duration_ms > 0:
        seconds = duration_ms // 1000
        minutes = seconds // 60
        secs = seconds % 60
        return f"{minutes}:{secs:02d}"
    return ""

def filter_free_songs(songs: List[Dict]) -> List[Dict]:
    return [s for s in songs if isinstance(s, dict) and s.get("fee", 0) == 0] if songs else []

def validate_song_id(song_id: int) -> bool:
    return isinstance(song_id, int) and song_id > 0

def validate_playlist_id(playlist_id: int) -> bool:
    return isinstance(playlist_id, int) and playlist_id > 0

def validate_search_keyword(keyword: str) -> str:
    if not isinstance(keyword, str) or keyword.strip() == "":
        return UIConfig.DEFAULT_KEYWORD
    return keyword.strip()

class PlayQueueManager:
    def __init__(self, play_song_callback):
        self._queue: List[Dict] = []
        self._current_index: int = -1
        self._play_song = play_song_callback

    @property
    def queue(self) -> List[Dict]:
        return self._queue

    @queue.setter
    def queue(self, songs: List[Dict]):
        self._queue = songs
        self._current_index = -1

    @property
    def current_index(self) -> int:
        return self._current_index

    @current_index.setter
    def current_index(self, idx: int):
        if 0 <= idx < len(self._queue):
            self._current_index = idx

    async def try_play_from(self, start_idx: int) -> bool:
        for i in range(start_idx, len(self._queue)):
            song_id = self._queue[i].get("id")
            success = await self._play_song(song_id)
            if success:
                self._current_index = i
                return True
        return False

    async def play_next(self) -> bool:
        return await self.try_play_from(self._current_index + 1)

    async def play_from_beginning(self) -> bool:
        return await self.try_play_from(0)

    def remove_song(self, song_id: int):
        for i, s in enumerate(self._queue):
            if s.get("id") == song_id:
                del self._queue[i]
                if self._current_index > i:
                    self._current_index -= 1
                break

class AudioCache:
    def __init__(self):
        self.temp_dir = tempfile.gettempdir()

    def get_cache_path(self, song_id: int) -> str:
        return os.path.join(self.temp_dir, f"flet_music_{song_id}.Music_31107")

    def is_cached(self, song_id: int) -> bool:
        return os.path.exists(self.get_cache_path(song_id))

    async def download(self, song_id: int, song_url: str) -> str:
        cache_path = self.get_cache_path(song_id)
        if self.is_cached(song_id):
            return cache_path
        await asyncio.to_thread(urllib.request.urlretrieve, song_url, cache_path)
        return cache_path

class AudioPlayer:
    def __init__(self):
        self.is_initialized = False
        self.is_playing = False
        self.playback_finished = False
        self.current_position_ms = 0
        self.current_duration_ms = 0
        self.current_file: Optional[str] = None
        self.progress_task: Optional[asyncio.Task] = None
        self.on_play_complete: Optional[Callable[[], Awaitable[None]]] = None
        self._progress_running = False

    def _ensure_initialized(self):
        if not self.is_initialized:
            pygame.mixer.init()
            self.is_initialized = True

    def stop(self):
        try:
            self.is_playing = False
            self.playback_finished = False
            self._stop_progress_task()
            pygame.mixer.music.stop()
            pygame.mixer.music.unload()
            time.sleep(0.1)
        except Exception:
            pass

    def pause(self):
        if self.is_playing:
            pygame.mixer.music.pause()
            self.is_playing = False

    def unpause(self):
        if not self.is_playing and not self.playback_finished:
            pygame.mixer.music.unpause()
            self.is_playing = True

    async def play_file(self, file_path: str, duration_ms: int):
        self.stop()
        self._ensure_initialized()

        self.current_file = file_path
        self.current_duration_ms = duration_ms
        self.current_position_ms = 0
        self.playback_finished = False

        pygame.mixer.music.load(file_path)
        pygame.mixer.music.play()
        self.is_playing = True
        self._start_progress_task()

    def replay(self):
        if self.current_file and os.path.exists(self.current_file):
            pygame.mixer.music.load(self.current_file)
            pygame.mixer.music.play()
            self.current_position_ms = 0
            self.playback_finished = False
            self.is_playing = True
            self._start_progress_task()

    def _start_progress_task(self):
        if self.progress_task and not self.progress_task.done():
            return
        self._progress_running = True
        self.progress_task = asyncio.create_task(self._update_progress())

    def _stop_progress_task(self):
        self._progress_running = False
        if self.progress_task and not self.progress_task.done():
            self.progress_task.cancel()
            self.progress_task = None

    async def _update_progress(self):
        while self._progress_running:
            await asyncio.sleep(0.5)
            if not self._progress_running:
                break
            try:
                if self.is_playing:
                    if pygame.mixer.music.get_busy():
                        self.current_position_ms = pygame.mixer.music.get_pos()
                    else:
                        self.is_playing = False
                        self.playback_finished = True
                        self._stop_progress_task()
                        if self.on_play_complete:
                            asyncio.create_task(self.on_play_complete())
            except Exception:
                break

class PlayerUI:
    def __init__(self, page: ft.Page, audio_player: AudioPlayer, on_replay_all: Optional[Callable[[], None]] = None):
        self.page = page
        self.audio_player = audio_player
        self.on_replay_all = on_replay_all
        self._running = True

        self.play_button = ft.IconButton(
            icon=ft.Icons.PLAY_CIRCLE,
            icon_size=48,
            icon_color=PRIMARY,
            on_click=self._toggle_play,
            disabled=True
        )
        self.song_title = ft.Text("点击歌曲播放", size=16, weight="bold", color=GRAY)
        self.song_artist = ft.Text("", size=12, color=GRAY)
        self.progress_bar = ft.ProgressBar(
            value=0, bgcolor=ft.Colors.with_opacity(0.12, BLACK),
            color=ft.Colors.with_opacity(0.85, PRIMARY),
            height=6, border_radius=3
        )
        self.time_current = ft.Text("0:00", size=11, color=ft.Colors.with_opacity(0.75, BLACK))
        self.time_total = ft.Text("0:00", size=11, color=ft.Colors.with_opacity(0.75, BLACK))
        self.song_cover = ft.Image(
            src="https://via.placeholder.com/64",
            width=UIConfig.COVER_SIZE_SMALL,
            height=UIConfig.COVER_SIZE_SMALL,
            fit="cover",
            border_radius=8
        )

        self._setup_progress_updater()

    def dispose(self):
        self._running = False

    def _setup_progress_updater(self):
        asyncio.create_task(self._update_ui_loop())

    async def _update_ui_loop(self):
        while self._running:
            await asyncio.sleep(0.25)
            if self._running:
                self._update_ui()

    def _update_ui(self):
        self.play_button.icon = ft.Icons.PAUSE_CIRCLE if self.audio_player.is_playing else ft.Icons.PLAY_CIRCLE

        if self.audio_player.current_duration_ms > 0:
            progress = min(
                self.audio_player.current_position_ms / self.audio_player.current_duration_ms,
                1.0
            )
            self.progress_bar.value = progress
            self.time_current.value = format_duration(self.audio_player.current_position_ms)

        try:
            self.page.update()
        except (RuntimeError, Exception):
            self._running = False

    def update_song_info(self, song: Dict[str, Any]):
        if not isinstance(song, dict):
            return

        song_name = song.get("name", "未知歌曲")
        artists = song.get("ar", [])
        artist_name = ", ".join([a.get("name", "未知歌手") for a in artists]) if artists else ""
        duration = song.get("dt", 0)

        self.audio_player.current_duration_ms = duration
        self.time_total.value = format_duration(duration)
        self.song_title.value = song_name
        self.song_title.color = BLACK
        self.song_artist.value = artist_name

        cover_url = song.get("al", {}).get("picUrl")
        if cover_url:
            self.song_cover.src = f"{cover_url}?param={UIConfig.COVER_SIZE_SMALL}x{UIConfig.COVER_SIZE_SMALL}"

        self.play_button.disabled = False
        self.page.update()

    def _toggle_play(self, _):
        if not self.audio_player.current_file:
            return

        if self.audio_player.is_playing:
            self.audio_player.pause()
        else:
            if self.audio_player.playback_finished:
                if self.on_replay_all:
                    self.on_replay_all()
                else:
                    self.audio_player.replay()
            elif pygame.mixer.music.get_pos() > 0:
                self.audio_player.unpause()
            else:
                self.audio_player.replay()

        self._update_ui()

    def get_ui(self) -> ft.Container:
        return ft.Container(
            content=ft.Row([
                self.song_cover, ft.Container(width=12),
                ft.Column([self.song_title, self.song_artist], spacing=2, alignment=ft.MainAxisAlignment.CENTER),
                ft.Container(width=16),
                ft.Container(
                    content=ft.Row([
                        ft.Container(content=self.time_current, padding=ft.Padding.only(left=8, top=3, right=8, bottom=3),
                                     bgcolor=ft.Colors.with_opacity(0.12, BLACK), border_radius=6),
                        ft.Container(width=8),
                        ft.Container(content=self.progress_bar, expand=True, padding=ft.Padding.only(left=4, top=8, right=4, bottom=8),
                                     bgcolor=ft.Colors.with_opacity(0.12, BLACK), border_radius=12),
                        ft.Container(width=8),
                        ft.Container(content=self.time_total, padding=ft.Padding.only(left=8, top=3, right=8, bottom=3),
                                     bgcolor=ft.Colors.with_opacity(0.12, BLACK), border_radius=6),
                    ], spacing=0, vertical_alignment=ft.CrossAxisAlignment.CENTER),
                    padding=ft.Padding.only(left=12, top=8, right=12, bottom=8), bgcolor=ft.Colors.with_opacity(0.06, BLACK),
                    border_radius=12, expand=True
                ),
                ft.Container(width=16), self.play_button
            ], spacing=0, vertical_alignment=ft.CrossAxisAlignment.CENTER),
            padding=ft.Padding.only(left=16, top=14, right=16, bottom=14), bgcolor=WHITE,
            shadow=ft.BoxShadow(
                blur_radius=12, spread_radius=2, offset=ft.Offset(0, -4),
                color=ft.Colors.with_opacity(0.12, BLACK)
            )
        )

class MusicPlayer:
    def __init__(self, page: ft.Page, on_replay_all: Optional[Callable[[], None]] = None):
        self.page = page
        self.audio_cache = AudioCache()
        self.audio_player = AudioPlayer()
        self.player_ui = PlayerUI(page, self.audio_player, on_replay_all)

    @property
    def on_play_complete(self):
        return self.audio_player.on_play_complete

    @on_play_complete.setter
    def on_play_complete(self, callback):
        self.audio_player.on_play_complete = callback

    async def play(self, song: Dict[str, Any], song_url: str):
        self.player_ui.update_song_info(song)
        song_id = song.get("id", 0)
        cache_path = await self.audio_cache.download(song_id, song_url)
        duration = song.get("dt", 0)
        await self.audio_player.play_file(cache_path, duration)

    def get_ui(self) -> ft.Container:
        return self.player_ui.get_ui()

class MusicAPI:
    def __init__(self):
        self._cache: Dict[str, CacheEntry] = {}
        self._song_index: Dict[int, Dict[str, Any]] = {}

    def _update_song_index(self, tracks: List[Dict[str, Any]]):
        for track in tracks:
            if isinstance(track, dict):
                song_id = track.get("id")
                if validate_song_id(song_id):
                    self._song_index[song_id] = track

    def find_song_info(self, song_id: int) -> Optional[Dict[str, Any]]:
        return self._song_index.get(song_id) if validate_song_id(song_id) else None

    @cached()
    async def search_playlists(self, keyword: str = UIConfig.DEFAULT_KEYWORD,
                               limit: int = UIConfig.DEFAULT_LIMIT) -> List[Dict[str, Any]]:
        keyword = validate_search_keyword(keyword)
        try:
            result = await asyncio.to_thread(
                cloudsearch.GetSearchResult, keyword,
                stype=cloudsearch.PLAYLIST, limit=limit
            )
            if result.get("code") == 200:
                return result.get("result", {}).get("playlists", [])
            return []
        except Exception:
            return []

    @cached()
    async def search_songs(self, keyword: str = "",
                           limit: int = UIConfig.DEFAULT_LIMIT) -> List[Dict[str, Any]]:
        keyword = validate_search_keyword(keyword)
        try:
            result = await asyncio.to_thread(
                cloudsearch.GetSearchResult, keyword,
                stype=cloudsearch.SONG, limit=limit
            )
            if result.get("code") == 200:
                songs = result.get("result", {}).get("songs", [])
                self._update_song_index(songs)
                return songs
            return []
        except Exception:
            return []

    @cached()
    async def get_playlist_tracks(self, playlist_id: int) -> List[Dict[str, Any]]:
        if not validate_playlist_id(playlist_id):
            return []
        try:
            result = await asyncio.to_thread(apis.playlist.GetPlaylistInfo, playlist_id)
            if result.get("code") == 200:
                tracks = result.get("playlist", {}).get("tracks", [])
                self._update_song_index(tracks)
                return tracks
            return []
        except Exception:
            return []

    async def get_song_url(self, song_id: int) -> Optional[str]:
        if not validate_song_id(song_id):
            return None
        try:
            result = await asyncio.to_thread(apis.track.GetTrackAudio, song_id)
            if result.get("code") == 200:
                data = result.get("data", [])
                if data:
                    track_data = data[0]
                    url = track_data.get("url")
                    free_trial_info = track_data.get("freeTrialInfo")
                    if free_trial_info is not None or not url:
                        return None
                    return url
            return None
        except Exception:
            return None

class UIManager:
    def __init__(self, page: ft.Page, on_search_callback, on_switch_view_callback, on_playlist_click_callback):
        self.page = page
        self.on_search = on_search_callback
        self.on_switch_view = on_switch_view_callback
        self.on_playlist_click = on_playlist_click_callback

        self.search_input: Optional[ft.TextField] = None
        self.playlists_grid: Optional[ft.GridView] = None
        self.songs_grid: Optional[ft.GridView] = None
        self.main_container: Optional[ft.Column] = None
        self.playlists_button: Optional[ft.Button] = None
        self.songs_button: Optional[ft.Button] = None

    @staticmethod
    def _create_button(text: str, on_click, is_active: bool = False) -> ft.Button:
        return ft.Button(
            content=ft.Text(text, color=WHITE if is_active else BLACK),
            on_click=on_click,
            style=ft.ButtonStyle(
                shape=ft.RoundedRectangleBorder(radius=8),
                bgcolor=PRIMARY if is_active else LIGHT_GRAY
            ),
        )

    @staticmethod
    def _create_cover_card(data: Dict, on_click, is_playlist: bool = True) -> ft.Card:
        if not isinstance(data, dict):
            data = {}

        if is_playlist:
            cover_url = data.get("coverImgUrl", "https://via.placeholder.com/200")
            title = data.get("name", "未知歌单")
            subtitle = f"{data.get('trackCount', 0)}首歌曲"
            duration_text = ""
        else:
            cover_url = data.get("al", {}).get("picUrl", "https://via.placeholder.com/200")
            title = data.get("name", "未知歌曲")
            artists = data.get("ar", [])
            subtitle = ", ".join([a.get("name", "未知歌手") for a in artists]) if artists else ""
            duration = data.get("dt", 0)
            duration_text = format_duration(duration)

        if cover_url:
            cover_url += "?param=200x200"

        image_area = ft.Container(
            width=UIConfig.COVER_SIZE_LARGE,
            height=UIConfig.COVER_SIZE_LARGE,
            content=ft.Stack([
                ft.Image(
                    src=cover_url, width=UIConfig.COVER_SIZE_LARGE,
                    height=UIConfig.COVER_SIZE_LARGE, fit="cover",
                    error_content=ft.Image(src="https://via.placeholder.com/180"),
                ),
            ] + ([
                ft.Container(
                    content=ft.Text(duration_text, size=12, color=WHITE, weight="bold"),
                    bgcolor=ft.Colors.with_opacity(0.7, BLACK),
                    padding=ft.Padding.only(left=6, top=3, right=6, bottom=3), border_radius=4,
                    right=8, bottom=8,
                )
            ] if duration_text and not is_playlist else [])),
        )

        return ft.Card(
            content=ft.GestureDetector(
                content=ft.Container(
                    content=ft.Column([
                        image_area,
                        ft.Container(
                            content=ft.Column([
                                ft.Text(title, size=14, color=BLACK, weight="bold", max_lines=2, overflow=ft.TextOverflow.ELLIPSIS),
                                ft.Text(subtitle, size=12, color=GRAY),
                            ], spacing=5),
                            padding=10,
                        ),
                    ], spacing=0),
                    width=UIConfig.COVER_SIZE_LARGE, padding=0,
                ),
                on_tap=on_click,
            ),
        )

    @staticmethod
    def _create_song_list_item(track: Dict) -> ft.Row:
        def _create_tag(text: str) -> ft.Container:
            return ft.Container(
                content=ft.Text(text, size=11, color=ft.Colors.with_opacity(0.85, BLACK)),
                padding=ft.Padding.only(left=6, top=2, right=6, bottom=2),
                bgcolor=ft.Colors.with_opacity(0.05, BLACK),
                border_radius=4,
            )

        if isinstance(track, dict):
            song_name = track.get("name", "未知歌曲")
            artists = track.get("ar", [])
            artist_names = ", ".join([a.get("name", "未知歌手") for a in artists])
            cover_url = track.get("al", {}).get("picUrl", "https://via.placeholder.com/56")
            duration = track.get("dt", 0)
        else:
            song_name = f"歌曲ID: {track}"
            artist_names = "未知歌手"
            cover_url = "https://via.placeholder.com/56"
            duration = 0

        if cover_url:
            cover_url += f"?param={UIConfig.COVER_SIZE_LIST}x{UIConfig.COVER_SIZE_LIST}"

        duration_text = format_duration(duration)

        tags = [_create_tag(artist_names)]
        if duration_text:
            tags.extend([ft.Container(width=8), _create_tag(duration_text)])

        return ft.Row([
            ft.Image(
                src=cover_url, width=UIConfig.COVER_SIZE_LIST,
                height=UIConfig.COVER_SIZE_LIST, fit="cover",
                error_content=ft.Image(src="https://via.placeholder.com/56"),
            ),
            ft.Column([
                ft.Text(song_name, size=16, color=BLACK, weight="bold"),
                ft.Row(tags, spacing=0),
            ], spacing=4, expand=True, alignment=ft.MainAxisAlignment.CENTER),
        ], spacing=12, vertical_alignment=ft.CrossAxisAlignment.CENTER)

    def setup_main_page(self):
        self.page.controls.clear()

        self.search_input = ft.TextField(hint_text="搜索", border=ft.InputBorder.UNDERLINE, text_size=16, width=180, on_submit=self.on_search)

        header = ft.Container(
            content=ft.Row([
                ft.Text("免费听整首歌曲", size=22, color=BLACK, weight="bold"),
                ft.Container(expand=True),
                ft.Row([
                    self.search_input,
                    ft.Container(width=8),
                    self._create_button("搜索", on_click=self.on_search, is_active=True),
                ], spacing=0),
            ], alignment=ft.MainAxisAlignment.SPACE_BETWEEN),
            padding=ft.Padding.only(left=16, top=16, right=16, bottom=12), bgcolor=WHITE
        )

        self.playlists_button = self._create_button(
            "歌单",
            on_click=lambda _: self.on_switch_view(ViewType.PLAYLISTS),
            is_active=True
        )
        self.songs_button = self._create_button(
            "单曲",
            on_click=lambda _: self.on_switch_view(ViewType.SONGS)
        )

        view_buttons = ft.Container(
            content=ft.Row([self.playlists_button, self.songs_button], spacing=12, alignment=ft.MainAxisAlignment.CENTER),
            padding=ft.Padding.only(left=16, top=8, right=16, bottom=8), bgcolor=WHITE
        )

        self.playlists_grid = ft.GridView(
            expand=True, runs_count=UIConfig.GRID_RUNS_COUNT,
            max_extent=UIConfig.GRID_MAX_EXTENT, spacing=UIConfig.GRID_SPACING,
            run_spacing=UIConfig.GRID_SPACING
        )
        self.songs_grid = ft.GridView(
            expand=True, runs_count=UIConfig.GRID_RUNS_COUNT,
            max_extent=UIConfig.GRID_MAX_EXTENT, spacing=UIConfig.GRID_SPACING,
            run_spacing=UIConfig.GRID_SPACING
        )

        self.main_container = ft.Column(expand=True)
        self.main_container.controls.append(self.playlists_grid)

        return ft.Column([header, view_buttons, self.main_container], expand=True)

    def show_tracks_page(self, playlist_name: str, on_back_callback):
        self.page.controls.clear()

        list_view = ft.ListView(
            expand=True, spacing=8,
            padding=ft.Padding.only(left=16, top=12, right=16, bottom=12)
        )

        header = ft.Container(
            content=ft.Row([
                self._create_button("返回", on_click=on_back_callback, is_active=True),
                ft.Text(playlist_name, size=20, color=BLACK, weight="bold"),
            ], alignment=ft.MainAxisAlignment.START),
            padding=ft.Padding.only(left=16, top=16, right=16, bottom=12), bgcolor=WHITE
        )

        return ft.Column([header, list_view], expand=True), list_view

    def switch_view(self, view_type: ViewType):
        if self.main_container:
            self.main_container.controls.clear()
            if view_type == ViewType.PLAYLISTS:
                self.main_container.controls.append(self.playlists_grid)
                self._update_button_colors(True)
            else:
                self.main_container.controls.append(self.songs_grid)
                self._update_button_colors(False)
            self.page.update()

    def _update_button_colors(self, is_playlists_active: bool):
        if self.playlists_button:
            self.playlists_button.style.bgcolor = PRIMARY if is_playlists_active else LIGHT_GRAY
            self.playlists_button.content = ft.Text("歌单", color=WHITE if is_playlists_active else BLACK)
        if self.songs_button:
            self.songs_button.style.bgcolor = LIGHT_GRAY if is_playlists_active else PRIMARY
            self.songs_button.content = ft.Text("单曲", color=BLACK if is_playlists_active else WHITE)

    def get_search_keyword(self) -> str:
        return self.search_input.value.strip() if self.search_input else ""

    @staticmethod
    def show_loading(container):
        container.controls.clear()
        container.controls.append(ft.Text("加载中...", color=GRAY))

    @staticmethod
    def show_empty(container, message: str = "暂无可播放的免费歌曲"):
        container.controls.clear()
        container.controls.append(ft.Text(message, color=GRAY))

    @staticmethod
    def show_error(container, message: str):
        container.controls.clear()
        container.controls.append(ft.Text(message, color=ft.Colors.RED))

    def render_songs(self, songs: List[Dict], container, is_grid: bool, play_song_callback, play_queue):
        if not container:
            return

        container.controls.clear()
        if not songs:
            self.show_empty(container)
            self.page.update()
            return

        play_queue.queue = songs
        item_map: Dict[int, Any] = {}

        for idx, song in enumerate(songs):
            song_id = song.get("id")

            async def on_click_handler(_, sid=song_id, idx_copy=idx):
                success = await play_song_callback(sid)
                if success:
                    play_queue.current_index = idx_copy
                elif not success and sid in item_map:
                    container.controls.remove(item_map[sid])
                    del item_map[sid]
                    play_queue.remove_song(sid)
                    self.page.update()

            if is_grid:
                item = self._create_cover_card(song, on_click_handler, is_playlist=False)
            else:
                item = ft.GestureDetector(
                    content=self._create_song_list_item(song),
                    on_tap=on_click_handler
                )
            container.controls.append(item)
            item_map[song_id] = item

        self.page.update()

    def render_playlists(self, playlists: List[Dict], container, on_playlist_click):
        if not container:
            return

        container.controls.clear()
        if not playlists:
            self.show_empty(container, "暂无公共歌单")
        else:
            for pl in playlists:
                card = self._create_cover_card(
                    pl,
                    lambda _, pid=pl.get("id"), pname=pl.get("name"): asyncio.create_task(
                        on_playlist_click(pid, pname)
                    ),
                    is_playlist=True
                )
                container.controls.append(card)
        self.page.update()

class MusicPlayerApp:
    def __init__(self, page: ft.Page):
        self.page = page
        self.page.title = "FletMusic"
        self.page.theme_mode = ft.ThemeMode.LIGHT
        self.page.bgcolor = LIGHT_GRAY
        self.page.padding = 0
        self.page.window_width = 480
        self.page.window_height = 800

        self.api = MusicAPI()
        self.search_keyword = UIConfig.DEFAULT_KEYWORD
        self.ui_manager = UIManager(page, self._on_search, self._on_switch_view, self._on_playlist_click)
        self.play_queue = PlayQueueManager(self.play_song)
        self.player = MusicPlayer(page, self._on_replay_all)
        self.player.on_play_complete = self._on_play_complete

    async def play_song(self, song_id: int) -> bool:
        if not validate_song_id(song_id):
            return False

        try:
            song_info = self.api.find_song_info(song_id)
            if not song_info:
                self._show_error("未找到歌曲信息")
                return False

            song_url = await self.api.get_song_url(song_id)
            if not song_url:
                self._show_error("这是一首试听歌曲,无法播放完整版本")
                return False

            await self.player.play(song_info, song_url)
            return True
        except Exception:
            return False

    def _show_error(self, message: str):
        self.page.snack_bar = ft.SnackBar(content=ft.Text(message))
        self.page.snack_bar.open = True
        self.page.update()

    async def load_playlists(self, keyword: str = UIConfig.DEFAULT_KEYWORD):
        keyword = validate_search_keyword(keyword)
        if not self.ui_manager.playlists_grid:
            return

        self.ui_manager.show_loading(self.ui_manager.playlists_grid)
        self.page.update()

        try:
            playlists = await self.api.search_playlists(keyword)
            self.ui_manager.render_playlists(playlists, self.ui_manager.playlists_grid, self._on_playlist_click)
        except Exception:
            self.ui_manager.show_error(self.ui_manager.playlists_grid, "加载歌单失败")

    async def load_songs(self, keyword: str = ""):
        keyword = validate_search_keyword(keyword)
        if not self.ui_manager.songs_grid:
            return

        self.ui_manager.show_loading(self.ui_manager.songs_grid)
        self.page.update()

        try:
            songs = await self.api.search_songs(keyword)
            filtered_songs = filter_free_songs(songs)
            self.ui_manager.render_songs(filtered_songs, self.ui_manager.songs_grid, True,
                                        self.play_song, self.play_queue)
        except Exception:
            self.ui_manager.show_error(self.ui_manager.songs_grid, "加载单曲失败")

    def _on_search(self, _):
        keyword = validate_search_keyword(self.ui_manager.get_search_keyword())
        self.search_keyword = keyword
        asyncio.create_task(self.load_playlists(keyword))
        asyncio.create_task(self.load_songs(keyword))

    def _on_switch_view(self, view_type: ViewType):
        self.ui_manager.switch_view(view_type)

    async def _on_playlist_click(self, playlist_id: int, playlist_name: str):
        if not validate_playlist_id(playlist_id):
            return

        main_content, list_view = self.ui_manager.show_tracks_page(playlist_name, self._show_main_page)
        self.page.add(ft.Column([main_content, self.player.get_ui()], expand=True))

        self.ui_manager.show_loading(list_view)
        self.page.update()

        try:
            tracks = await self.api.get_playlist_tracks(playlist_id)
            filtered_tracks = filter_free_songs(tracks)
            self.ui_manager.render_songs(filtered_tracks, list_view, False,
                                        self.play_song, self.play_queue)
        except Exception:
            self.ui_manager.show_error(list_view, "加载失败")
            self.page.update()

    def _show_main_page(self):
        main_content = self.ui_manager.setup_main_page()
        self.page.add(ft.Column([main_content, self.player.get_ui()], expand=True))
        asyncio.create_task(self.load_playlists(self.search_keyword))
        asyncio.create_task(self.load_songs(self.search_keyword))

    async def _on_play_complete(self):
        await self.play_queue.play_next()

    def _on_replay_all(self):
        if self.play_queue.queue:
            asyncio.create_task(self.play_queue.play_from_beginning())

    def run(self):
        self._show_main_page()

def main(page: ft.Page):
    app = MusicPlayerApp(page)
    app.run()

if __name__ == "__main__":
    ft.run(main, view=ft.AppView.FLET_APP)

打包配置:FletMusic.spec

# -*- mode: python ; coding: utf-8 -*-
import os
import site

# 查找 flet 库的安装路径
flet_path = None
for p in site.getsitepackages():
    test_path = os.path.join(p, 'flet')
    if os.path.exists(test_path):
        flet_path = p
        break

# 只收集 flet 的资源文件(不需要打包图标,只需要嵌入 exe)
datas = []
if flet_path:
    datas.append((os.path.join(flet_path, 'flet'), 'flet'))

a = Analysis(
    ['fletmusic.py'],
    pathex=[os.path.abspath('.')],
    binaries=[],
    datas=datas,
    hiddenimports=['flet'],  # 只保留必要的
    hookspath=[],
    hooksconfig={},
    runtime_hooks=[],
    excludes=[],
    noarchive=False,
    optimize=0,
)
pyz = PYZ(a.pure)

exe = EXE(
    pyz,
    a.scripts,
    a.binaries,
    a.datas,
    [],
    name='FletMusic',
    debug=False,
    bootloader_ignore_signals=False,
    strip=False,
    upx=True,
    upx_exclude=[],
    runtime_tmpdir=None,
    console=False,
    disable_windowed_traceback=False,
    argv_emulation=False,
    target_arch=None,
    codesign_identity=None,
    entitlements_file=None,
    icon=['Music_31107.ico'],
)

一行指令,打包带走

代码调试完成后,在终端中执行以下命令即可生成可执行文件:

pyinstaller FletMusic.spec

最终执行产物会放在 dist/FletMusic.exe,体积大约 35MB,相当小巧。

LEARN BY!

在复刻这个小项目的过程中,我也再次深刻体会到一点:很多时候没必要急着 造轮子,先用最轻量的姿势验证想法,后续再让 AI 帮我们精雕细琢,这大概就是属于这个时代的极致开发体验了。




上一篇:把Python程序打包成Windows安装包,用Pynsist就对了
下一篇:下一代计算架构终局:抛弃操作系统,走向硬件隔离+纯微内核+全域沙盒
您需要登录后才可以回帖 登录 | 立即注册

手机版|小黑屋|网站地图|云栈社区 ( 苏ICP备2022046150号-2 )

GMT+8, 2026-5-5 22:27 , Processed in 1.652978 second(s), 41 queries , Gzip On.

Powered by Discuz! X3.5

© 2025-2026 云栈社区.

快速回复 返回顶部 返回列表