#!/usr/bin/env python3
# -*- coding: utf-8 -*-
import os
import re
import json
import time
import threading
import bisect
from urllib.parse import urlparse
import requests
from kivy.app import App
from kivy.clock import Clock, mainthread
from kivy.core.audio import SoundLoader
from kivy.core.text import LabelBase, DEFAULT_FONT
from kivy.metrics import dp, sp
from kivy.uix.boxlayout import BoxLayout
from kivy.uix.button import Button
from kivy.uix.gridlayout import GridLayout
from kivy.uix.label import Label
from kivy.uix.popup import Popup
from kivy.uix.screenmanager import ScreenManager, Screen, SlideTransition
from kivy.uix.scrollview import ScrollView
from kivy.uix.spinner import Spinner
from kivy.uix.textinput import TextInput
from kivy.utils import platform
# ================================================================
# 系统中文字体(按美观度排序)
# ================================================================
def _find_system_cjk_font():
if platform == "android":
# 优先使用各家 ROM 定制的现代字体,越靠前越好看
candidates = [
"/system/fonts/MiSans-Regular.ttf",
"/system/fonts/MiSansVF.ttf",
"/system/fonts/MiSans-Normal.ttf",
"/system/fonts/HarmonyOS_Sans_SC.ttf",
"/system/fonts/HarmonyOS_Sans_SC_Regular.ttf",
"/system/fonts/HarmonyOS_Sans_SC_Medium.ttf",
"/system/fonts/OPPOSans-Regular.ttf",
"/system/fonts/OPPOSans3.0.ttf",
"/system/fonts/OPPOSans4.0.ttf",
"/system/fonts/OnePlusSans-Regular.ttf",
"/system/fonts/vivo_font.ttf",
"/system/fonts/NotoSansSC-Regular.otf",
"/system/fonts/NotoSansCJKsc-Regular.otf",
"/system/fonts/NotoSansCJK-Regular.ttc",
"/system/fonts/DroidSansFallbackFull.ttf",
"/system/fonts/DroidSansFallback.ttf",
"/system/fonts/DroidSansChinese.ttf",
]
elif platform == "win":
candidates = [
"C:/Windows/Fonts/msyh.ttc", # 微软雅黑
"C:/Windows/Fonts/msyh.ttf",
"C:/Windows/Fonts/msyhl.ttc",
"C:/Windows/Fonts/Deng.ttf", # 等线
"C:/Windows/Fonts/simhei.ttf", # 黑体
"C:/Windows/Fonts/simsun.ttc", # 宋体
]
elif platform == "macosx":
candidates = [
"/System/Library/Fonts/PingFang.ttc", # 苹方
"/System/Library/Fonts/STHeiti Medium.ttc",
"/System/Library/Fonts/STHeiti Light.ttc",
"/Library/Fonts/Songti.ttc",
"/Library/Fonts/Arial Unicode.ttf",
]
else:
candidates = [
"/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc",
"/usr/share/fonts/opentype/noto/NotoSansCJKsc-Regular.otf",
"/usr/share/fonts/truetype/noto/NotoSansCJK-Regular.ttc",
"/usr/share/fonts/truetype/wqy/wqy-microhei.ttc",
"/usr/share/fonts/truetype/wqy/wqy-zenhei.ttc",
]
for p in candidates:
if os.path.exists(p):
return p
return None
def _setup_cjk_font():
path = _find_system_cjk_font()
if not path:
return None
try:
LabelBase.register(name="CJK", fn_regular=path)
LabelBase.register(name="Roboto", fn_regular=path)
LabelBase.register(DEFAULT_FONT, fn_regular=path)
except Exception:
return None
return path
CJK_FONT_PATH = _setup_cjk_font()
FONT_NAME = "CJK" if CJK_FONT_PATH else "Roboto"
# ================================================================
# Android 导入
# ================================================================
_IS_ANDROID = (platform == "android")
autoclass = None
request_permissions = None
if _IS_ANDROID:
try:
from jnius import autoclass
except Exception:
pass
try:
from android.permissions import request_permissions
except Exception:
pass
# ================================================================
# UI 线程执行器
# ================================================================
_ui_runnable_cls = None
if _IS_ANDROID:
try:
from jnius import PythonJavaClass, java_method
class _UiRunnable(PythonJavaClass):
__javainterfaces__ = ['java/lang/Runnable']
__javacontext__ = 'app'
def __init__(self, callback):
super().__init__()
self._callback = callback
@java_method('()V')
def run(self):
try:
self._callback()
except Exception:
pass
_ui_runnable_cls = _UiRunnable
except Exception:
pass
def run_on_ui_thread(callback):
if not _IS_ANDROID or autoclass is None or _ui_runnable_cls is None:
callback()
return
try:
PythonActivity = autoclass("org.kivy.android.PythonActivity")
activity = PythonActivity.mActivity
if activity is None:
callback()
return
activity.runOnUiThread(_ui_runnable_cls(callback))
except Exception:
try:
callback()
except Exception:
pass
# ================================================================
# 屏幕常亮
# ================================================================
def set_keep_screen_on(enable):
if not _IS_ANDROID or autoclass is None:
return
try:
PythonActivity = autoclass("org.kivy.android.PythonActivity")
activity = PythonActivity.mActivity
if activity is None:
return
WindowManager = autoclass("android.view.WindowManager$LayoutParams")
def _apply():
try:
window = activity.getWindow()
if enable:
window.addFlags(WindowManager.FLAG_KEEP_SCREEN_ON)
else:
window.clearFlags(WindowManager.FLAG_KEEP_SCREEN_ON)
except Exception:
pass
run_on_ui_thread(_apply)
except Exception:
pass
# ================================================================
# 唤醒锁
# ================================================================
class WakeLockHelper:
def __init__(self):
self._lock = None
def acquire(self):
if not _IS_ANDROID or autoclass is None:
return
if self._lock is not None:
return
try:
PythonActivity = autoclass("org.kivy.android.PythonActivity")
Context = autoclass("android.content.Context")
PowerManager = autoclass("android.os.PowerManager")
activity = PythonActivity.mActivity
if activity is None:
return
pm = activity.getSystemService(Context.POWER_SERVICE)
lock = pm.newWakeLock(
PowerManager.PARTIAL_WAKE_LOCK, "GDMusic::playback")
lock.setReferenceCounted(False)
lock.acquire()
self._lock = lock
except Exception:
pass
def release(self):
if self._lock is None:
return
try:
if self._lock.isHeld():
self._lock.release()
self._lock = None
except Exception:
self._lock = None
# ================================================================
# MediaSession
# ================================================================
class MediaSessionHelper:
def __init__(self):
self._session = None
def activate(self, title="", artist=""):
if not _IS_ANDROID or autoclass is None:
return
try:
PythonActivity = autoclass("org.kivy.android.PythonActivity")
MediaSession = autoclass("android.media.session.MediaSession")
PlaybackState = autoclass("android.media.session.PlaybackState")
PlaybackStateBuilder = autoclass(
"android.media.session.PlaybackState$Builder")
MediaMetadata = autoclass("android.media.MediaMetadata")
MetadataBuilder = autoclass("android.media.MediaMetadata$Builder")
String = autoclass("java.lang.String")
activity = PythonActivity.mActivity
if activity is None:
return
session = MediaSession(activity, "GDMusicSession")
try:
session.setFlags(
MediaSession.FLAG_HANDLES_MEDIA_BUTTONS
| MediaSession.FLAG_HANDLES_TRANSPORT_CONTROLS)
except Exception:
pass
session.setActive(True)
state = PlaybackStateBuilder()
state.setState(PlaybackState.STATE_PLAYING, 0, 1.0)
state.setActions(
PlaybackState.ACTION_PLAY
| PlaybackState.ACTION_PAUSE
| PlaybackState.ACTION_STOP
| PlaybackState.ACTION_PLAY_PAUSE
| PlaybackState.ACTION_SEEK_TO)
session.setPlaybackState(state.build())
mb = MetadataBuilder()
mb.putString(MediaMetadata.METADATA_KEY_TITLE,
String(title or "GD音乐台"))
mb.putString(MediaMetadata.METADATA_KEY_ARTIST,
String(artist or ""))
session.setMetadata(mb.build())
self._session = session
except Exception:
pass
def deactivate(self):
if self._session is None:
return
try:
self._session.setActive(False)
self._session.release()
except Exception:
pass
self._session = None
# ================================================================
# 通知栏
# ================================================================
class NotificationHelper:
CHANNEL_ID = "gdmusic_playback"
NOTIFICATION_ID = 1001
def __init__(self):
self._channel_created = False
def _ensure_channel(self, activity):
if self._channel_created:
return
try:
Context = autoclass("android.content.Context")
NotificationChannel = autoclass("android.app.NotificationChannel")
channel = NotificationChannel(self.CHANNEL_ID, "音乐播放", 3)
try:
channel.setDescription("显示正在播放的歌曲")
except Exception:
pass
manager = activity.getSystemService(Context.NOTIFICATION_SERVICE)
if manager is not None:
manager.createNotificationChannel(channel)
self._channel_created = True
except Exception:
pass
def show(self, title, artist=""):
if not _IS_ANDROID or autoclass is None:
return
try:
PythonActivity = autoclass("org.kivy.android.PythonActivity")
NotificationBuilder = autoclass("android.app.Notification$Builder")
PendingIntent = autoclass("android.app.PendingIntent")
Intent = autoclass("android.content.Intent")
Context = autoclass("android.content.Context")
activity = PythonActivity.mActivity
if activity is None:
return
self._ensure_channel(activity)
pending = None
try:
intent = Intent(activity, activity.getClass())
intent.setFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP)
flags = PendingIntent.FLAG_UPDATE_CURRENT
if hasattr(PendingIntent, "FLAG_IMMUTABLE"):
flags |= PendingIntent.FLAG_IMMUTABLE
pending = PendingIntent.getActivity(
activity, 0, intent, flags)
except Exception:
pending = None
try:
R_drawable = autoclass("android.R$drawable")
small_icon = R_drawable.ic_media_play
except Exception:
small_icon = activity.getApplicationInfo().icon
try:
builder = NotificationBuilder(activity, self.CHANNEL_ID)
except Exception:
builder = NotificationBuilder(activity)
builder.setContentTitle(title or "GD音乐台")
builder.setContentText(artist or "正在播放")
builder.setSmallIcon(small_icon)
builder.setOngoing(True)
builder.setAutoCancel(False)
builder.setOnlyAlertOnce(True)
if pending is not None:
builder.setContentIntent(pending)
manager = activity.getSystemService(Context.NOTIFICATION_SERVICE)
manager.notify(self.NOTIFICATION_ID, builder.build())
except Exception:
pass
def hide(self):
if not _IS_ANDROID or autoclass is None:
return
try:
PythonActivity = autoclass("org.kivy.android.PythonActivity")
Context = autoclass("android.content.Context")
activity = PythonActivity.mActivity
if activity is None:
return
manager = activity.getSystemService(Context.NOTIFICATION_SERVICE)
manager.cancel(self.NOTIFICATION_ID)
except Exception:
pass
# ================================================================
# 常量
# ================================================================
API_BASE = "https://music-api.gdstudio.xyz/api.php"
SOURCES = ["netease", "tencent", "kuwo", "tidal", "qobuz",
"joox", "bilibili", "apple", "ytmusic", "spotify"]
BR_DEFAULT = "999"
AUDIO_EXTS = ("mp3", "flac", "m4a", "aac", "wav", "ogg", "ape", "wma")
MIME_MAP = {
"mp3": "audio/mpeg", "flac": "audio/flac", "m4a": "audio/mp4",
"aac": "audio/aac", "wav": "audio/wav", "ogg": "audio/ogg",
"ape": "audio/x-ape", "wma": "audio/x-ms-wma",
"lrc": "text/plain", "txt": "text/plain",
}
SYNC_INTERVAL = 0.1
LRC_RE = re.compile(r"\[(\d+):(\d+(?:[.:]\d+)?)\]")
# ================================================================
# 工具函数
# ================================================================
def s_(v, default=""):
if v is None:
return default
if isinstance(v, (list, tuple)):
return " / ".join(str(x) for x in v if x)
return str(v)
def sanitize(name):
return re.sub(r'[\\/:*?"<>|\r\n\t]', "_", name or "").strip()[:100]
def guess_ext(url, default="mp3"):
try:
ext = os.path.splitext(urlparse(url).path)[1].lstrip(".").lower()
except Exception:
ext = ""
return ext if ext in AUDIO_EXTS else default
def fmt_time(sec):
try:
sec = max(0, int(sec))
except Exception:
sec = 0
return "%02d:%02d" % (sec // 60, sec % 60)
def parse_lrc(text):
out = []
if not text:
return out
for line in text.splitlines():
line = line.strip()
if not line:
continue
stamps = list(LRC_RE.finditer(line))
if not stamps:
continue
content = line[stamps[-1].end():].strip()
if not content:
continue
for m in stamps:
try:
sec = int(m.group(1)) * 60 + float(m.group(2).replace(":", "."))
except ValueError:
continue
out.append((sec, content))
out.sort(key=lambda x: x[0])
return out
# ================================================================
# API
# ================================================================
class MusicAPI:
def __init__(self):
self.session = requests.Session()
self.session.headers.update({
"User-Agent": ("Mozilla/5.0 (Linux; Android 12) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/124.0.0.0 Mobile Safari/537.36"),
"Referer": "https://music.gdstudio.xyz/",
"Accept": "application/json, text/plain, */*",
})
def _get(self, **params):
resp = self.session.get(API_BASE, params=params, timeout=25)
resp.raise_for_status()
try:
return resp.json()
except ValueError:
raise RuntimeError("接口返回无法解析:%s" % resp.text[:120])
def search(self, name, source="netease", count=20, page=1):
return self._get(types="search", source=source, name=name,
count=count, pages=page)
def get_url(self, track_id, source="netease", br="999"):
return self._get(types="url", source=source, id=track_id, br=br)
def get_lyric(self, lyric_id, source="netease"):
return self._get(types="lyric", source=source, id=lyric_id)
# ================================================================
# 播放器
# ================================================================
class AudioPlayer:
def __init__(self):
self._sound = None
self._mp = None
self.path = None
self.duration = 0.0
self._start_time = 0.0
self._pause_offset = 0.0
self._android = _IS_ANDROID and autoclass is not None
def load(self, path):
self.stop()
self.path = path
self.duration = 0.0
self._pause_offset = 0.0
if not path or not os.path.exists(path):
return False
if self._android:
try:
MediaPlayer = autoclass("android.media.MediaPlayer")
mp = MediaPlayer()
mp.setDataSource(path)
mp.prepare()
self._mp = mp
self.duration = mp.getDuration() / 1000.0
return True
except Exception:
self._mp = None
return False
try:
snd = SoundLoader.load(path)
except Exception:
snd = None
if snd is None:
return False
self._sound = snd
self.duration = float(getattr(snd, "length", 0) or 0)
return True
def play(self):
try:
if self._mp is not None:
self._mp.start()
self._start_time = time.time() - self._pause_offset
return True
if self._sound is not None:
self._sound.play()
self._start_time = time.time() - self._pause_offset
return True
except Exception:
pass
return False
def pause(self):
try:
if self._mp is not None:
if self._mp.isPlaying():
self._mp.pause()
self._pause_offset = self._mp.getCurrentPosition() / 1000.0
return
if self._sound is not None:
self._pause_offset = self.get_position()
self._sound.stop()
except Exception:
pass
def stop(self):
try:
if self._mp is not None:
try:
self._mp.stop()
self._mp.release()
except Exception:
pass
self._mp = None
if self._sound is not None:
try:
self._sound.stop()
except Exception:
pass
self._sound = None
except Exception:
pass
self._start_time = 0.0
self._pause_offset = 0.0
def is_playing(self):
try:
if self._mp is not None:
return bool(self._mp.isPlaying())
if self._sound is not None:
return self._sound.state == "play"
except Exception:
pass
return False
def get_position(self):
try:
if self._mp is not None:
return max(0.0, self._mp.getCurrentPosition() / 1000.0)
if self._sound is not None:
get_pos = getattr(self._sound, "get_pos", None)
if callable(get_pos):
try:
p = get_pos()
if p is not None and p >= 0:
return float(p)
except Exception:
pass
if self._sound.state == "play" and self._start_time:
return max(0.0, time.time() - self._start_time)
return max(0.0, self._pause_offset)
except Exception:
pass
return 0.0
# ================================================================
# 本地库
# ================================================================
class MusicLibrary:
def __init__(self, base_dir):
self.base_dir = base_dir
os.makedirs(self.base_dir, exist_ok=True)
def audio_path(self, stem, ext):
return os.path.join(self.base_dir, "%s.%s" % (stem, ext))
def lrc_path(self, stem):
return os.path.join(self.base_dir, "%s.lrc" % stem)
def t_lrc_path(self, stem):
return os.path.join(self.base_dir, "%s.t.lrc" % stem)
def find_audio(self, stem):
for ext in AUDIO_EXTS:
p = self.audio_path(stem, ext)
if os.path.exists(p):
return p
return None
def save_lyric(self, stem, lyric_text, trans_text=""):
with open(self.lrc_path(stem), "w", encoding="utf-8") as f:
f.write(lyric_text or "")
if trans_text:
with open(self.t_lrc_path(stem), "w", encoding="utf-8") as f:
f.write(trans_text)
def load_lyric(self, stem):
p = self.lrc_path(stem)
if not os.path.exists(p):
return ""
try:
with open(p, "r", encoding="utf-8") as f:
return f.read()
except Exception:
return ""
def load_trans(self, stem):
p = self.t_lrc_path(stem)
if not os.path.exists(p):
return ""
try:
with open(p, "r", encoding="utf-8") as f:
return f.read()
except Exception:
return ""
def list_all(self):
items = []
try:
names = os.listdir(self.base_dir)
except OSError:
return items
for fn in names:
stem, ext = os.path.splitext(fn)
if ext.lstrip(".").lower() not in AUDIO_EXTS:
continue
items.append({
"stem": stem,
"audio": os.path.join(self.base_dir, fn),
"has_lyric": os.path.exists(self.lrc_path(stem)),
"has_trans": os.path.exists(self.t_lrc_path(stem)),
})
items.sort(key=lambda x: x["stem"].lower())
return items
def delete(self, stem):
audio = self.find_audio(stem)
if audio and os.path.exists(audio):
try:
os.remove(audio)
except OSError:
pass
for p in (self.lrc_path(stem), self.t_lrc_path(stem)):
if os.path.exists(p):
try:
os.remove(p)
except OSError:
pass
# ================================================================
# 播放列表
# ================================================================
class Playlist:
def __init__(self, path):
self.path = path
self.items = []
self.load()
def load(self):
if not os.path.exists(self.path):
return
try:
with open(self.path, "r", encoding="utf-8") as f:
data = json.load(f)
if isinstance(data, list):
self.items = [str(x) for x in data if x]
except Exception:
self.items = []
def save(self):
try:
os.makedirs(os.path.dirname(self.path), exist_ok=True)
with open(self.path, "w", encoding="utf-8") as f:
json.dump(self.items, f, ensure_ascii=False, indent=2)
except Exception:
pass
def add(self, stem):
if stem in self.items:
return False
self.items.append(stem)
self.save()
return True
def remove(self, stem):
if stem in self.items:
self.items.remove(stem)
self.save()
return True
return False
def clear(self):
self.items = []
self.save()
def index(self, stem):
try:
return self.items.index(stem)
except ValueError:
return -1
# ================================================================
# 分享
# ================================================================
class ShareHelper:
@staticmethod
def share_file(file_path, mime="*/*", chooser_title="分享"):
if not _IS_ANDROID or not file_path or not os.path.exists(file_path):
return False
content_uri = None
try:
from androidstorage4kivy import SharedStorage
storage = SharedStorage()
content_uri = storage.copy_to_shared(file_path)
except Exception:
content_uri = None
if content_uri is None:
return ShareHelper._share_with_file_uri(
file_path, mime, chooser_title)
if ShareHelper._share_with_content_uri(
content_uri, mime, chooser_title):
return True
return ShareHelper._share_with_view(content_uri, mime)
@staticmethod
def _share_with_content_uri(content_uri, mime, chooser_title):
try:
from jnius import cast
PythonActivity = autoclass("org.kivy.android.PythonActivity")
Intent = autoclass("android.content.Intent")
String = autoclass("java.lang.String")
activity = PythonActivity.mActivity
if activity is None:
return False
intent = Intent(Intent.ACTION_SEND)
intent.setType(mime)
intent.putExtra(
Intent.EXTRA_STREAM,
cast("android.os.Parcelable", content_uri))
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
chooser = Intent.createChooser(
intent,
cast("java.lang.CharSequence", String(chooser_title)))
activity.startActivity(chooser)
return True
except Exception:
return False
@staticmethod
def _share_with_view(content_uri, mime):
try:
PythonActivity = autoclass("org.kivy.android.PythonActivity")
Intent = autoclass("android.content.Intent")
activity = PythonActivity.mActivity
intent = Intent(Intent.ACTION_VIEW)
intent.setDataAndType(content_uri, mime)
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
activity.startActivity(intent)
return True
except Exception:
return False
@staticmethod
def _share_with_file_uri(file_path, mime, chooser_title):
try:
from jnius import cast
PythonActivity = autoclass("org.kivy.android.PythonActivity")
Intent = autoclass("android.content.Intent")
String = autoclass("java.lang.String")
File = autoclass("java.io.File")
Uri = autoclass("android.net.Uri")
activity = PythonActivity.mActivity
intent = Intent(Intent.ACTION_SEND)
intent.setType(mime)
uri = Uri.fromFile(File(file_path))
intent.putExtra(
Intent.EXTRA_STREAM,
cast("android.os.Parcelable", uri))
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
chooser = Intent.createChooser(
intent,
cast("java.lang.CharSequence", String(chooser_title)))
activity.startActivity(chooser)
return True
except Exception:
return False
@staticmethod
def share_text(text, subject="", chooser_title="分享"):
if not _IS_ANDROID:
return False
try:
from jnius import cast
PythonActivity = autoclass("org.kivy.android.PythonActivity")
Intent = autoclass("android.content.Intent")
String = autoclass("java.lang.String")
activity = PythonActivity.mActivity
intent = Intent(Intent.ACTION_SEND)
intent.setType("text/plain")
if subject:
intent.putExtra(
Intent.EXTRA_SUBJECT,
cast("java.lang.CharSequence", String(subject)))
intent.putExtra(
Intent.EXTRA_TEXT,
cast("java.lang.CharSequence", String(text)))
chooser = Intent.createChooser(
intent,
cast("java.lang.CharSequence", String(chooser_title)))
activity.startActivity(chooser)
return True
except Exception:
return False
# ================================================================
# 列表控件
# ================================================================
class ResultRow(Button):
def __init__(self, rec, idx, on_click, **kwargs):
super().__init__(
size_hint_y=None, height=dp(60),
halign="left", valign="middle",
font_size=sp(13), font_name=FONT_NAME,
background_normal="", background_color=(1, 1, 1, 1),
color=(0.15, 0.15, 0.15, 1),
**kwargs)
self.rec = rec
self.text = "%d. %s\n%s · %s · [%s]" % (
idx, rec["name"], rec["artist"], rec["album"], rec["source"])
self.bind(size=lambda inst, sz: setattr(
inst, "text_size", (sz[0] - dp(16), sz[1])))
self.bind(on_release=lambda *_: on_click(self.rec))
class LibraryRow(BoxLayout):
def __init__(self, item, on_play, on_delete, **kwargs):
super().__init__(orientation="horizontal",
size_hint_y=None, height=dp(54),
spacing=dp(4), **kwargs)
self.stem = item["stem"]
tag = "♪" if item["has_lyric"] else " "
display = "%s %s" % (tag, self.stem)
btn = Button(
text=display, halign="left", valign="middle",
font_size=sp(12), font_name=FONT_NAME,
background_normal="", background_color=(1, 1, 1, 1),
color=(0.15, 0.15, 0.15, 1))
btn.bind(size=lambda inst, sz: setattr(
inst, "text_size", (sz[0] - dp(8), sz[1])))
btn.bind(on_release=lambda *_: on_play(self.stem))
self.add_widget(btn)
del_btn = Button(
text="✕", size_hint_x=None, width=dp(44),
font_size=sp(14), font_name=FONT_NAME,
background_normal="", background_color=(0.92, 0.4, 0.4, 1),
color=(1, 1, 1, 1))
del_btn.bind(on_release=lambda *_: on_delete(self.stem))
self.add_widget(del_btn)
class PlaylistRow(BoxLayout):
def __init__(self, stem, idx, is_playing, on_play, on_delete, **kwargs):
super().__init__(orientation="horizontal",
size_hint_y=None, height=dp(54),
spacing=dp(4), **kwargs)
self.stem = stem
prefix = "▶ " if is_playing else "%d. " % (idx + 1)
bg = (0.85, 0.91, 0.99, 1) if is_playing else (1, 1, 1, 1)
fg = (0.10, 0.45, 0.92, 1) if is_playing else (0.15, 0.15, 0.15, 1)
btn = Button(
text=prefix + stem, halign="left", valign="middle",
font_size=sp(12), font_name=FONT_NAME,
background_normal="", background_color=bg, color=fg)
btn.bind(size=lambda inst, sz: setattr(
inst, "text_size", (sz[0] - dp(8), sz[1])))
btn.bind(on_release=lambda *_: on_play(idx))
self.add_widget(btn)
del_btn = Button(
text="✕", size_hint_x=None, width=dp(44),
font_size=sp(14), font_name=FONT_NAME,
background_normal="", background_color=(0.92, 0.4, 0.4, 1),
color=(1, 1, 1, 1))
del_btn.bind(on_release=lambda *_: on_delete(stem))
self.add_widget(del_btn)
# ================================================================
# 更多菜单
# ================================================================
class MoreMenuPopup(Popup):
def __init__(self, app, **kwargs):
super().__init__(
title="更多操作", size_hint=(0.82, 0.62),
title_font=FONT_NAME,
separator_color=(0.10, 0.45, 0.92, 1), **kwargs)
self.app = app
layout = BoxLayout(orientation="vertical",
spacing=dp(8), padding=dp(12))
items = [
("删除", app.delete_current),
("分享歌曲", app.share_current_song),
("分享歌词", app.share_current_lyric),
("添加到播放列表", app.add_current_to_playlist),
]
for text, cb in items:
btn = Button(
text=text, size_hint_y=None, height=dp(52),
font_size=sp(15), font_name=FONT_NAME,
background_normal="",
background_color=(0.94, 0.96, 1, 1),
color=(0.12, 0.12, 0.12, 1))
btn.bind(on_release=lambda inst, f=cb: self._do(f))
layout.add_widget(btn)
cancel = Button(
text="取消", size_hint_y=None, height=dp(46),
font_size=sp(14), font_name=FONT_NAME,
background_normal="",
background_color=(0.85, 0.85, 0.85, 1),
color=(0.2, 0.2, 0.2, 1))
cancel.bind(on_release=lambda *_: self.dismiss())
layout.add_widget(cancel)
self.content = layout
def _do(self, fn):
self.dismiss()
try:
fn()
except Exception:
pass
# ================================================================
# 搜索界面
# ================================================================
class SearchScreen(Screen):
def __init__(self, app, **kwargs):
self.app = app
super().__init__(**kwargs)
self._build()
def _build(self):
root = BoxLayout(orientation="vertical",
padding=dp(8), spacing=dp(6))
bar = BoxLayout(size_hint_y=None, height=dp(46), spacing=dp(6))
self.key_input = TextInput(
hint_text="歌名 / 歌手 / 专辑", multiline=False,
size_hint_x=0.5, font_size=sp(14), font_name=FONT_NAME)
self.key_input.bind(on_text_validate=self.on_search)
bar.add_widget(self.key_input)
self.source_spinner = Spinner(
text="netease", values=SOURCES,
size_hint_x=0.22, font_size=sp(12), font_name=FONT_NAME)
bar.add_widget(self.source_spinner)
btn = Button(text="搜索", size_hint_x=0.14,
font_size=sp(13), font_name=FONT_NAME)
btn.bind(on_release=self.on_search)
bar.add_widget(btn)
lib_btn = Button(text="本地", size_hint_x=0.14,
font_size=sp(13), font_name=FONT_NAME)
lib_btn.bind(on_release=lambda *_: self.app.show_library())
bar.add_widget(lib_btn)
root.add_widget(bar)
self.status_label = Label(
text="输入关键词搜索,点击结果即可下载并播放",
size_hint_y=None, height=dp(24),
font_size=sp(12), color=(0.45, 0.45, 0.45, 1),
font_name=FONT_NAME)
root.add_widget(self.status_label)
scroll = ScrollView()
self.grid = GridLayout(cols=1, spacing=dp(2), size_hint_y=None)
self.grid.bind(minimum_height=self.grid.setter("height"))
scroll.add_widget(self.grid)
root.add_widget(scroll)
self.add_widget(root)
def on_search(self, *args):
keyword = self.key_input.text.strip()
if not keyword:
self.status_label.text = "请输入关键词"
return
self.key_input.focus = False
self.status_label.text = "搜索中..."
self.grid.clear_widgets()
threading.Thread(target=self._search_worker,
args=(keyword, self.source_spinner.text),
daemon=True).start()
def _search_worker(self, keyword, source):
try:
data = self.app.api.search(keyword, source, count=30, page=1)
except Exception as e:
self._on_result([], "搜索失败:%s" % e)
return
if isinstance(data, dict):
for key in ("data", "result", "songs", "list"):
if isinstance(data.get(key), list):
data = data[key]
break
else:
msg = data.get("msg") or data.get("message") or "未知错误"
self._on_result([], "搜索失败:%s" % msg)
return
results = []
if isinstance(data, list):
for item in data:
if not isinstance(item, dict):
continue
results.append({
"id": s_(item.get("id")),
"name": s_(item.get("name"), "未知曲目"),
"artist": s_(item.get("artist"), "未知歌手"),
"album": s_(item.get("album"), "未知专辑"),
"lyric_id": s_(item.get("lyric_id")) or s_(item.get("id")),
"source": s_(item.get("source")) or source,
})
self._on_result(results, None)
@mainthread
def _on_result(self, results, err):
self.grid.clear_widgets()
if err:
self.status_label.text = err
return
if not results:
self.status_label.text = "未找到结果"
return
self.status_label.text = "共 %d 条 · 点击即下载并播放" % len(results)
for i, rec in enumerate(results):
self.grid.add_widget(ResultRow(
rec, i + 1, self.app.on_result_click))
# ================================================================
# 播放界面
# ================================================================
class PlayerScreen(Screen):
def __init__(self, app, **kwargs):
self.app = app
super().__init__(**kwargs)
self.show_trans = True
self.lyric_times = []
self.lyric_widgets = []
self.current_idx = -2
self._build()
def _build(self):
root = BoxLayout(orientation="vertical",
padding=dp(8), spacing=dp(6))
top = BoxLayout(size_hint_y=None, height=dp(48), spacing=dp(6))
back = Button(text="← 返回", size_hint_x=0.25,
font_size=sp(13), font_name=FONT_NAME)
back.bind(on_release=lambda *_: self.app.show_search())
top.add_widget(back)
self.title_label = Label(
text="未在播放", halign="left", valign="middle",
font_size=sp(14), color=(0.10, 0.40, 0.90, 1),
font_name=FONT_NAME)
self.title_label.bind(size=lambda inst, sz: setattr(
inst, "text_size", (sz[0], sz[1])))
top.add_widget(self.title_label)
root.add_widget(top)
self.lyric_scroll = ScrollView(bar_width=dp(3))
self.lyric_grid = GridLayout(
cols=1, spacing=dp(4), padding=(dp(12), dp(30)),
size_hint_y=None)
self.lyric_grid.bind(minimum_height=self.lyric_grid.setter("height"))
self.lyric_scroll.add_widget(self.lyric_grid)
root.add_widget(self.lyric_scroll)
time_row = BoxLayout(size_hint_y=None, height=dp(20), spacing=dp(8))
self.pos_label = Label(text="00:00", size_hint_x=None, width=dp(56),
font_size=sp(11), color=(0.4, 0.4, 0.4, 1),
font_name=FONT_NAME)
self.dur_label = Label(text="00:00", size_hint_x=None, width=dp(56),
font_size=sp(11), color=(0.4, 0.4, 0.4, 1),
font_name=FONT_NAME)
time_row.add_widget(self.pos_label)
time_row.add_widget(Label(text="", size_hint_x=1))
time_row.add_widget(self.dur_label)
root.add_widget(time_row)
ctrl = BoxLayout(size_hint_y=None, height=dp(50), spacing=dp(6))
self.play_btn = Button(text="▶ 播放", size_hint_x=0.25,
font_size=sp(13), font_name=FONT_NAME)
self.play_btn.bind(on_release=lambda *_: self.app.toggle_pause())
ctrl.add_widget(self.play_btn)
stop_btn = Button(text="■ 停止", size_hint_x=0.25,
font_size=sp(13), font_name=FONT_NAME)
stop_btn.bind(on_release=lambda *_: self.app.stop_play())
ctrl.add_widget(stop_btn)
more_btn = Button(text="⋯ 更多", size_hint_x=0.25,
font_size=sp(13), font_name=FONT_NAME)
more_btn.bind(on_release=lambda *_: self.app.open_more_menu())
ctrl.add_widget(more_btn)
self.trans_btn = Button(text="翻译:开", size_hint_x=0.25,
font_size=sp(13), font_name=FONT_NAME)
self.trans_btn.bind(on_release=lambda *_: self._toggle_trans())
ctrl.add_widget(self.trans_btn)
root.add_widget(ctrl)
self.add_widget(root)
self.show_placeholder("暂无歌词")
def set_title(self, text):
self.title_label.text = text
def set_duration(self, sec):
self.dur_label.text = fmt_time(sec)
def set_position(self, sec):
self.pos_label.text = fmt_time(sec)
def set_play_btn(self, playing):
self.play_btn.text = "▷ 暂停" if playing else "▶ 播放"
def show_placeholder(self, msg):
self.lyric_grid.clear_widgets()
self.lyric_times = []
self.lyric_widgets = []
self.current_idx = -2
lbl = Label(text=msg, size_hint_y=None, height=dp(40),
font_size=sp(13), color=(0.55, 0.55, 0.55, 1),
halign="center", valign="middle",
font_name=FONT_NAME)
self.lyric_grid.add_widget(lbl)
def render_lyrics(self, orig, trans):
self.lyric_grid.clear_widgets()
self.lyric_times = []
self.lyric_widgets = []
self.current_idx = -2
if not orig:
self.show_placeholder("暂无歌词")
return
trans_map = {}
if self.show_trans and trans:
for t, txt in trans:
trans_map[round(t, 2)] = txt
for t, content in orig:
lyric_lbl = Label(
text=content, size_hint_y=None,
halign="center", valign="middle",
font_size=sp(16), color=(0.32, 0.32, 0.32, 1),
font_name=FONT_NAME)
lyric_lbl.bind(width=lambda inst, w: setattr(
inst, "text_size", (w - dp(16), None)))
lyric_lbl.bind(texture_size=lambda inst, ts: setattr(
inst, "height", max(ts[1] + dp(14), dp(30))))
self.lyric_grid.add_widget(lyric_lbl)
tr = trans_map.get(round(t, 2))
if tr:
trans_lbl = Label(
text=tr, size_hint_y=None,
halign="center", valign="middle",
font_size=sp(11), color=(0.60, 0.60, 0.60, 1),
font_name=FONT_NAME)
trans_lbl.bind(width=lambda inst, w: setattr(
inst, "text_size", (w - dp(16), None)))
trans_lbl.bind(texture_size=lambda inst, ts: setattr(
inst, "height", max(ts[1] + dp(8), dp(20))))
self.lyric_grid.add_widget(trans_lbl)
self.lyric_times.append(t)
self.lyric_widgets.append(lyric_lbl)
def highlight(self, idx):
if idx == self.current_idx:
return
if 0 <= self.current_idx < len(self.lyric_widgets):
old = self.lyric_widgets[self.current_idx]
old.color = (0.32, 0.32, 0.32, 1)
old.font_size = sp(16)
if 0 <= idx < len(self.lyric_widgets):
lbl = self.lyric_widgets[idx]
lbl.color = (0.10, 0.45, 0.92, 1)
lbl.font_size = sp(18)
self._scroll_to(lbl)
self.current_idx = idx
def _scroll_to(self, widget):
def do_scroll(dt):
try:
grid = self.lyric_grid
view = self.lyric_scroll
total = grid.height
vp = view.height
if total <= vp or total <= 0:
return
target = widget.center_y - vp / 2.0
sy = target / (total - vp)
view.scroll_y = max(0.0, min(1.0, sy))
except Exception:
pass
Clock.schedule_once(do_scroll, 0.02)
def _toggle_trans(self):
self.show_trans = not self.show_trans
self.trans_btn.text = "翻译:" + ("开" if self.show_trans else "关")
self.app.rerender_lyrics()
# ================================================================
# 本地库界面
# ================================================================
class LibraryScreen(Screen):
def __init__(self, app, **kwargs):
self.app = app
super().__init__(**kwargs)
self._build()
def _build(self):
root = BoxLayout(orientation="vertical",
padding=dp(8), spacing=dp(6))
top = BoxLayout(size_hint_y=None, height=dp(48), spacing=dp(6))
back = Button(text="← 返回", size_hint_x=0.25,
font_size=sp(13), font_name=FONT_NAME)
back.bind(on_release=lambda *_: self.app.show_search())
top.add_widget(back)
self.title_label = Label(
text="本地音乐库", halign="left", valign="middle",
font_size=sp(14), font_name=FONT_NAME)
self.title_label.bind(size=lambda inst, sz: setattr(
inst, "text_size", (sz[0], sz[1])))
top.add_widget(self.title_label)
refresh_btn = Button(text="刷新", size_hint_x=0.25,
font_size=sp(13), font_name=FONT_NAME)
refresh_btn.bind(on_release=lambda *_: self.refresh())
top.add_widget(refresh_btn)
list_btn = Button(text="列表", size_hint_x=0.25,
font_size=sp(13), font_name=FONT_NAME)
list_btn.bind(on_release=lambda *_: self.app.show_playlist())
top.add_widget(list_btn)
root.add_widget(top)
self.path_label = Label(
text="", size_hint_y=None, height=dp(20),
font_size=sp(10), color=(0.5, 0.5, 0.5, 1),
halign="left", font_name=FONT_NAME)
self.path_label.bind(size=lambda inst, sz: setattr(
inst, "text_size", (sz[0], sz[1])))
root.add_widget(self.path_label)
scroll = ScrollView()
self.grid = GridLayout(cols=1, spacing=dp(2), size_hint_y=None)
self.grid.bind(minimum_height=self.grid.setter("height"))
scroll.add_widget(self.grid)
root.add_widget(scroll)
self.add_widget(root)
def refresh(self):
self.grid.clear_widgets()
items = self.app.library.list_all()
self.path_label.text = "目录:" + self.app.library.base_dir
if not items:
self.grid.add_widget(Label(
text="本地还没有音乐\n去搜索页下载吧",
size_hint_y=None, height=dp(60),
font_size=sp(13), color=(0.55, 0.55, 0.55, 1),
font_name=FONT_NAME))
return
for item in items:
self.grid.add_widget(LibraryRow(
item,
on_play=self.app.play_from_library,
on_delete=self.app.delete_from_library))
def on_enter(self, *args):
self.refresh()
# ================================================================
# 播放列表界面
# ================================================================
class PlaylistScreen(Screen):
def __init__(self, app, **kwargs):
self.app = app
super().__init__(**kwargs)
self._build()
def _build(self):
root = BoxLayout(orientation="vertical",
padding=dp(8), spacing=dp(6))
top = BoxLayout(size_hint_y=None, height=dp(48), spacing=dp(6))
back = Button(text="← 返回", size_hint_x=0.25,
font_size=sp(13), font_name=FONT_NAME)
back.bind(on_release=lambda *_: self.app.show_library())
top.add_widget(back)
self.title_label = Label(
text="播放列表", halign="left", valign="middle",
font_size=sp(14), font_name=FONT_NAME)
self.title_label.bind(size=lambda inst, sz: setattr(
inst, "text_size", (sz[0], sz[1])))
top.add_widget(self.title_label)
loop_btn = Button(text="▶ 循环播放", size_hint_x=0.35,
font_size=sp(12), font_name=FONT_NAME)
loop_btn.bind(on_release=lambda *_: self.app.start_playlist_loop())
top.add_widget(loop_btn)
clear_btn = Button(text="清空", size_hint_x=0.22,
font_size=sp(12), font_name=FONT_NAME)
clear_btn.bind(on_release=lambda *_: self._clear())
top.add_widget(clear_btn)
root.add_widget(top)
self.hint_label = Label(
text="", size_hint_y=None, height=dp(20),
font_size=sp(10), color=(0.5, 0.5, 0.5, 1),
halign="left", font_name=FONT_NAME)
self.hint_label.bind(size=lambda inst, sz: setattr(
inst, "text_size", (sz[0], sz[1])))
root.add_widget(self.hint_label)
scroll = ScrollView()
self.grid = GridLayout(cols=1, spacing=dp(2), size_hint_y=None)
self.grid.bind(minimum_height=self.grid.setter("height"))
scroll.add_widget(self.grid)
root.add_widget(scroll)
self.add_widget(root)
def refresh(self):
self.grid.clear_widgets()
items = self.app.playlist.items
playing_stem = self.app.current_stem if self.app.loop_playlist else None
if not items:
self.hint_label.text = "播放列表为空"
self.grid.add_widget(Label(
text="在播放界面点「更多 → 添加到播放列表」",
size_hint_y=None, height=dp(60),
font_size=sp(13), color=(0.55, 0.55, 0.55, 1),
font_name=FONT_NAME))
return
self.hint_label.text = "共 %d 首 · 点击任意歌曲开始循环播放" % len(items)
for i, stem in enumerate(items):
self.grid.add_widget(PlaylistRow(
stem, i, stem == playing_stem,
on_play=self.app.play_from_playlist,
on_delete=self.app.remove_from_playlist))
def _clear(self):
self.app.playlist.clear()
self.app.loop_playlist = False
self.refresh()
def on_enter(self, *args):
self.refresh()
# ================================================================
# 主程序
# ================================================================
class GDMusicApp(App):
def build(self):
self.title = "GD 音乐台 · 离线版"
self.api = MusicAPI()
self.player = AudioPlayer()
self.current_stem = None
self.audio_path = None
self.lyric_orig = []
self.lyric_trans = []
self.playing = False
self.sync_event = None
self._download_lock = threading.Lock()
self.loop_playlist = False
self.playlist_index = -1
self.library = MusicLibrary(self._resolve_base_dir())
self.playlist = Playlist(os.path.join(
self.user_data_dir, "playlist.json"))
self.wakelock = WakeLockHelper()
self.media_session = MediaSessionHelper()
self.notification = NotificationHelper()
if _IS_ANDROID and request_permissions is not None:
perms = [
"android.permission.INTERNET",
"android.permission.READ_EXTERNAL_STORAGE",
"android.permission.WRITE_EXTERNAL_STORAGE",
"android.permission.WAKE_LOCK",
"android.permission.FOREGROUND_SERVICE",
"android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK",
"android.permission.POST_NOTIFICATIONS",
]
try:
request_permissions(perms)
except Exception:
pass
self.sm = ScreenManager(transition=SlideTransition(duration=0.2))
self.search_screen = SearchScreen(self, name="search")
self.player_screen = PlayerScreen(self, name="player")
self.library_screen = LibraryScreen(self, name="library")
self.playlist_screen = PlaylistScreen(self, name="playlist")
self.sm.add_widget(self.search_screen)
self.sm.add_widget(self.player_screen)
self.sm.add_widget(self.library_screen)
self.sm.add_widget(self.playlist_screen)
self.sm.current = "search"
return self.sm
def _resolve_base_dir(self):
if _IS_ANDROID:
base = os.path.join(self.user_data_dir, "GDMusic")
os.makedirs(base, exist_ok=True)
return base
return os.path.join(os.path.expanduser("~"), "GDMusic")
def show_search(self):
self.sm.current = "search"
def show_player(self):
self.sm.current = "player"
def show_library(self):
self.sm.current = "library"
def show_playlist(self):
self.sm.current = "playlist"
def on_result_click(self, rec):
stem = sanitize("%s - %s" % (rec["artist"], rec["name"])) or "unknown"
self.current_stem = stem
self.loop_playlist = False
self.playlist_index = -1
self.player_screen.set_title("%s --- %s" % (rec["name"], rec["artist"]))
self.show_player()
existing = self.library.find_audio(stem)
if existing:
self.player_screen.set_title("正在载入本地文件...")
Clock.schedule_once(
lambda dt: self._play_local(stem, existing), 0)
return
self.player_screen.show_placeholder("正在下载:%s..." % rec["name"])
self.player_screen.set_play_btn(False)
self.player_screen.set_position(0)
self.player_screen.set_duration(0)
threading.Thread(target=self._download_and_play,
args=(rec, stem), daemon=True).start()
def _download_and_play(self, rec, stem):
if not self._download_lock.acquire(blocking=False):
self._set_status("已有下载任务在进行,请稍候")
return
try:
self._set_status("获取播放链接...")
try:
data = self.api.get_url(rec["id"], rec["source"], BR_DEFAULT)
except Exception as e:
self._set_status("获取播放链接失败:%s" % e)
return
url = data.get("url") if isinstance(data, dict) else None
if not url:
self._set_status("未获取到播放链接")
return
ext = guess_ext(url)
audio_path = self.library.audio_path(stem, ext)
self._set_status("下载音频:%s" % rec["name"])
try:
self._download(url, audio_path)
except Exception as e:
self._set_status("下载失败:%s" % e)
return
self._set_status("下载歌词...")
try:
lyr = self.api.get_lyric(rec["lyric_id"], rec["source"])
if isinstance(lyr, dict):
self.library.save_lyric(
stem,
lyr.get("lyric") or "",
lyr.get("tlyric") or "")
except Exception:
pass
self._ui_call(lambda: self._play_local(stem, audio_path))
finally:
self._download_lock.release()
@mainthread
def _play_local(self, stem, audio_path, loop=False, playlist_index=None):
if self.sync_event:
self.sync_event.cancel()
self.sync_event = None
self.player.stop()
self.playing = False
self.current_stem = stem
self.audio_path = audio_path
if loop:
self.loop_playlist = True
if playlist_index is not None:
self.playlist_index = playlist_index
orig_text = self.library.load_lyric(stem)
trans_text = self.library.load_trans(stem)
self.lyric_orig = parse_lrc(orig_text)
self.lyric_trans = parse_lrc(trans_text)
self.show_player()
self.player_screen.render_lyrics(self.lyric_orig, self.lyric_trans)
self.player_screen.set_position(0)
self.player_screen.set_duration(0)
self.player_screen.set_play_btn(False)
if not self.player.load(audio_path):
self.player_screen.set_title("音频加载失败(格式可能不支持)")
return
self.player.play()
self.playing = True
self.player_screen.set_play_btn(True)
self.player_screen.set_duration(self.player.duration)
set_keep_screen_on(True)
self.wakelock.acquire()
self.media_session.activate(title=stem, artist="")
self.notification.show(title=stem, artist="正在播放")
if loop:
self.player_screen.set_title(
"正在播放(%d/%d)· %s"
% (self.playlist_index + 1,
len(self.playlist.items), stem))
else:
self.player_screen.set_title("正在播放 · %s" % stem)
self.sync_event = Clock.schedule_interval(
self._sync_loop, SYNC_INTERVAL)
def play_from_library(self, stem):
audio = self.library.find_audio(stem)
if not audio:
return
self.loop_playlist = False
self.playlist_index = -1
self._play_local(stem, audio)
def delete_from_library(self, stem):
if stem == self.current_stem:
self.stop_play()
self.current_stem = None
self.player_screen.show_placeholder("已删除当前歌曲")
self.library.delete(stem)
self.playlist.remove(stem)
self.library_screen.refresh()
def play_from_playlist(self, idx):
items = self.playlist.items
if idx < 0 or idx >= len(items):
return
stem = items[idx]
audio = self.library.find_audio(stem)
if not audio:
if len(items) > 1:
next_idx = (idx + 1) % len(items)
if next_idx != idx:
Clock.schedule_once(
lambda dt: self.play_from_playlist(next_idx), 0)
return
self._play_local(stem, audio, loop=True, playlist_index=idx)
def start_playlist_loop(self):
if not self.playlist.items:
return
self.loop_playlist = True
start = self.playlist.index(self.current_stem) \
if self.current_stem else -1
if start < 0:
start = 0
self.play_from_playlist(start)
def _advance_playlist(self):
items = self.playlist.items
if not items:
return
n = len(items)
for step in range(1, n + 1):
idx = (self.playlist_index + step) % n
stem = items[idx]
audio = self.library.find_audio(stem)
if audio:
self._play_local(stem, audio,
loop=True, playlist_index=idx)
return
self.player_screen.set_title("播放列表中的歌曲都未下载")
def remove_from_playlist(self, stem):
was_playing = (stem == self.current_stem and self.loop_playlist)
if self.playlist.remove(stem):
if was_playing:
self.stop_play()
self.loop_playlist = False
self.current_stem = None
else:
if self.playlist_index >= len(self.playlist.items):
self.playlist_index = len(self.playlist.items) - 1
self.playlist_screen.refresh()
def open_more_menu(self):
if not self.current_stem:
return
MoreMenuPopup(self).open()
def add_current_to_playlist(self):
if not self.current_stem:
return
ok = self.playlist.add(self.current_stem)
if ok:
self.player_screen.set_title(
"已加入播放列表 · %s" % self.current_stem)
else:
self.player_screen.set_title(
"已在播放列表中 · %s" % self.current_stem)
self.playlist_screen.refresh()
def share_current_song(self):
if not self.audio_path or not os.path.exists(self.audio_path):
self.player_screen.set_title("当前无音频文件可分享")
return
ext = os.path.splitext(self.audio_path)[1].lstrip(".").lower()
mime = MIME_MAP.get(ext, "audio/*")
ok = ShareHelper.share_file(self.audio_path, mime, "分享歌曲")
if ok:
self.player_screen.set_title("已打开分享面板 · 歌曲")
else:
self.player_screen.set_title("分享不可用")
def share_current_lyric(self):
if not self.current_stem:
return
lrc_path = self.library.lrc_path(self.current_stem)
if os.path.exists(lrc_path):
ok = ShareHelper.share_file(lrc_path, "text/plain", "分享歌词")
if ok:
self.player_screen.set_title("已打开分享面板 · 歌词")
return
text = self.library.load_lyric(self.current_stem)
if not text:
self.player_screen.set_title("本地没有这首歌的歌词")
return
ok2 = ShareHelper.share_text(
text, subject=self.current_stem, chooser_title="分享歌词")
if ok2:
self.player_screen.set_title("已打开分享面板 · 歌词文本")
else:
self.player_screen.set_title("分享不可用")
def delete_current(self):
if not self.current_stem:
return
self.delete_from_library(self.current_stem)
self.show_library()
def _sync_loop(self, dt):
if not self.playing:
return False
pos = self.player.get_position()
self.player_screen.set_position(pos)
times = self.player_screen.lyric_times
if times:
idx = bisect.bisect_right(times, pos) - 1
if idx != self.player_screen.current_idx:
self.player_screen.highlight(idx)
if not self.player.is_playing():
time.sleep(0.05)
if not self.player.is_playing():
self.playing = False
self.player_screen.set_play_btn(False)
self.sync_event = None
if self.loop_playlist and self.playlist.items:
Clock.schedule_once(
lambda d: self._advance_playlist(), 0.1)
return False
self.player_screen.set_title(
"播放结束 · %s" % self.current_stem)
set_keep_screen_on(False)
self.wakelock.release()
self.media_session.deactivate()
self.notification.hide()
return False
return True
def toggle_pause(self):
if not self.player.path:
return
if self.player.is_playing():
self.player.pause()
self.playing = False
self.player_screen.set_play_btn(False)
if self.sync_event:
self.sync_event.cancel()
self.sync_event = None
else:
dur = self.player.duration
pos = self.player.get_position()
if dur > 0 and pos >= dur - 0.6:
self.player.load(self.audio_path)
self.player.play()
self.playing = True
self.player_screen.set_play_btn(True)
set_keep_screen_on(True)
self.wakelock.acquire()
self.media_session.activate(
title=self.current_stem or "", artist="")
self.notification.show(
title=self.current_stem or "GD音乐台",
artist="正在播放")
if not self.sync_event:
self.sync_event = Clock.schedule_interval(
self._sync_loop, SYNC_INTERVAL)
def stop_play(self):
self.playing = False
self.loop_playlist = False
self.player.stop()
if self.sync_event:
self.sync_event.cancel()
self.sync_event = None
self.player_screen.set_play_btn(False)
self.player_screen.set_position(0)
set_keep_screen_on(False)
self.wakelock.release()
self.media_session.deactivate()
self.notification.hide()
def rerender_lyrics(self):
self.player_screen.render_lyrics(self.lyric_orig, self.lyric_trans)
def _download(self, url, path):
os.makedirs(os.path.dirname(path), exist_ok=True)
tmp = path + ".part"
try:
with self.api.session.get(url, stream=True, timeout=60) as r:
r.raise_for_status()
with open(tmp, "wb") as f:
for chunk in r.iter_content(64 * 1024):
if chunk:
f.write(chunk)
os.replace(tmp, path)
except Exception:
if os.path.exists(tmp):
try:
os.remove(tmp)
except OSError:
pass
raise
@mainthread
def _set_status(self, text):
self.player_screen.set_title(text)
def _ui_call(self, fn):
Clock.schedule_once(lambda dt: fn(), 0)
def on_stop(self):
self.playing = False
set_keep_screen_on(False)
self.wakelock.release()
self.media_session.deactivate()
self.notification.hide()
if self.sync_event:
self.sync_event.cancel()
self.sync_event = None
self.player.stop()
# ================================================================
# 入口
# ================================================================
if __name__ == "__main__":
GDMusicApp().run()