用kivy手搓一个网络音乐播放器

bash 复制代码
[app]

# (str) Title of your application
title = GDmusicApp
# python_version = 3.11.9
# p4a.extra_args = --python-version=3.11.9
# (str) Package name
package.name = gdmusic

# (str) Package domain (needed for android/ios packaging)
package.domain = org.gdstudio

# (str) Source code where the main.py live
source.dir = .

# (list) Source files to include (let empty to include all the files)
source.include_exts = py,png,jpg,kv,atlas,json

# (list) List of inclusions using pattern matching
#source.include_patterns = assets/*,images/*.png

# (list) Source files to exclude (let empty to not exclude anything)
#source.exclude_exts = spec

# (list) List of directory to exclude (let empty to not exclude anything)
#source.exclude_dirs = tests, bin, venv

# (list) List of exclusions using pattern matching
# Do not prefix with './'
#source.exclude_patterns = license,images/*/*.jpg

# (str) Application versioning (method 1)
version = 0.7

# (str) Application versioning (method 2)
# version.regex = __version__ = ['"](.*)['"]
# version.filename = %(source.dir)s/main.py

# (list) Application requirements
# comma separated e.g. requirements = sqlite3,kivy
requirements = hostpython3==3.11.9,python3==3.11.9,pip<24.0,kivy,requests,urllib3,chardet,idna,certifi,pyjnius,androidstorage4kivy

# (str) Custom source folders for requirements
# Sets custom source for any requirements with recipes
# requirements.source.kivy = ../../kivy

# (str) Presplash of the application
#presplash.filename = %(source.dir)s/data/presplash.png

# (str) Icon of the application
#icon.filename = %(source.dir)s/data/icon.png

# (list) Supported orientations
# Valid options are: landscape, portrait, portrait-reverse or landscape-reverse
orientation = portrait

# (list) List of service to declare
#services = NAME:ENTRYPOINT_TO_PY,NAME2:ENTRYPOINT2_TO_PY
# services = Mediaplayer:player_service.py:foreground:sticky
#
# OSX Specific
#

#
# author = © Copyright Info

# change the major version of python used by the app
osx.python_version = 3

# Kivy version to use
osx.kivy_version = 1.9.1

#
# Android specific
#

# (bool) Indicate if the application should be fullscreen or not
fullscreen = 0

# (string) Presplash background color (for android toolchain)
# Supported formats are: #RRGGBB #AARRGGBB or one of the following names:
# red, blue, green, black, white, gray, cyan, magenta, yellow, lightgray,
# darkgray, grey, lightgrey, darkgrey, aqua, fuchsia, lime, maroon, navy,
# olive, purple, silver, teal.
#android.presplash_color = #FFFFFF

# (string) Presplash animation using Lottie format.
# see https://lottiefiles.com/ for examples and https://airbnb.design/lottie/
# for general documentation.
# Lottie files can be created using various tools, like Adobe After Effect or Synfig.
#android.presplash_lottie = "path/to/lottie/file.json"

# (str) Adaptive icon of the application (used if Android API level is 26+ at runtime)
#icon.adaptive_foreground.filename = %(source.dir)s/data/icon_fg.png
#icon.adaptive_background.filename = %(source.dir)s/data/icon_bg.png

# (list) Permissions
# (See https://python-for-android.readthedocs.io/en/latest/buildoptions/#build-options-1 for all the supported syntaxes and properties)
# android.permissions =  INTERNET, READ_MEDIA_IMAGES, READ_EXTERNAL_STORAGE, WRITE_EXTERNAL_STORAGE, MANAGE_EXTERNAL_STORAGE
#android.permissions = android.permission.INTERNET, (name=android.permission.WRITE_EXTERNAL_STORAGE;maxSdkVersion=18)
android.permissions = INTERNET,READ_EXTERNAL_STORAGE,WRITE_EXTERNAL_STORAGE,WAKE_LOCK,FOREGROUND_SERVICE,FOREGROUND_SERVICE_MEDIA_PLAYBACK,POST_NOTIFICATIONS
# (list) features (adds uses-feature -tags to manifest)
#android.features = android.hardware.usb.host

# (int) Target Android API, should be as high as possible.
android.api = 33

# (int) Minimum API your APK / AAB will support.
android.minapi = 21

# (int) Android SDK version to use
#android.sdk = 20

# (str) Android NDK version to use
android.ndk = 25b

# (int) Android NDK API to use. This is the minimum API your app will support, it should usually match android.minapi.
#android.ndk_api = 21

# (bool) Use --private data storage (True) or --dir public storage (False)
#android.private_storage = True

# (str) Android NDK directory (if empty, it will be automatically downloaded.)
#android.ndk_path =

# (str) Android SDK directory (if empty, it will be automatically downloaded.)
#android.sdk_path =

# (str) ANT directory (if empty, it will be automatically downloaded.)
#android.ant_path =

# (bool) If True, then skip trying to update the Android sdk
# This can be useful to avoid excess Internet downloads or save time
# when an update is due and you just want to test/build your package
# android.skip_update = False

# (bool) If True, then automatically accept SDK license
# agreements. This is intended for automation only. If set to False,
# the default, you will be shown the license when first running
# buildozer.
# android.accept_sdk_license = False

android.foreground_service_type = mediaPlayback
# (str) Android entry point, default is ok for Kivy-based app
#android.entrypoint = org.kivy.android.PythonActivity

# (str) Full name including package path of the Java class that implements Android Activity
# use that parameter together with android.entrypoint to set custom Java class instead of PythonActivity
#android.activity_class_name = org.kivy.android.PythonActivity

# (str) Extra xml to write directly inside the <manifest> element of AndroidManifest.xml
# use that parameter to provide a filename from where to load your custom XML code
#android.extra_manifest_xml = ./src/android/extra_manifest.xml
# android.extra_manifest_xml = %(source.dir)s/manifest_extra.xml
# android.extra_manifest_application_xml = %(source.dir)s/manifest_extra.xml
# (str) Extra xml to write directly inside the <manifest><application> tag of AndroidManifest.xml
# use that parameter to provide a filename from where to load your custom XML arguments:
#android.extra_manifest_application_arguments = ./src/android/extra_manifest_application_arguments.xml

# (str) Full name including package path of the Java class that implements Python Service
# use that parameter to set custom Java class which extends PythonService
#android.service_class_name = org.kivy.android.PythonService

# (str) Android app theme, default is ok for Kivy-based app
# android.apptheme = "@android:style/Theme.NoTitleBar"

# (list) Pattern to whitelist for the whole project
#android.whitelist =

# (str) Path to a custom whitelist file
#android.whitelist_src =

# (str) Path to a custom blacklist file
#android.blacklist_src =

# (list) List of Java .jar files to add to the libs so that pyjnius can access
# their classes. Don't add jars that you do not need, since extra jars can slow
# down the build process. Allows wildcards matching, for example:
# OUYA-ODK/libs/*.jar
#android.add_jars = foo.jar,bar.jar,path/to/more/*.jar

# (list) List of Java files to add to the android project (can be java or a
# directory containing the files)
#android.add_src =

# (list) Android AAR archives to add
#android.add_aars =

# (list) Put these files or directories in the apk assets directory.
# Either form may be used, and assets need not be in 'source.include_exts'.
# 1) android.add_assets = source_asset_relative_path
# 2) android.add_assets = source_asset_path:destination_asset_relative_path
#android.add_assets =

# (list) Put these files or directories in the apk res directory.
# The option may be used in three ways, the value may contain one or zero ':'
# Some examples:
# 1) A file to add to resources, legal resource names contain ['a-z','0-9','_']
# android.add_resources = my_icons/all-inclusive.png:drawable/all_inclusive.png
# 2) A directory, here  'legal_icons' must contain resources of one kind
# android.add_resources = legal_icons:drawable
# 3) A directory, here 'legal_resources' must contain one or more directories, 
# each of a resource kind:  drawable, xml, etc...
# android.add_resources = legal_resources
#android.add_resources =
# android.add_resources = res/xml/file_paths.xml
# (list) Gradle dependencies to add
# android.gradle_dependencies = androidx.core:core:1.9.0

# (bool) Enable AndroidX support. Enable when 'android.gradle_dependencies'
# contains an 'androidx' package, or any package from Kotlin source.
# android.enable_androidx requires android.api >= 28
android.enable_androidx = True

# (list) add java compile options
# this can for example be necessary when importing certain java libraries using the 'android.gradle_dependencies' option
# see https://developer.android.com/studio/write/java8-support for further information
# android.add_compile_options = "sourceCompatibility = 1.8", "targetCompatibility = 1.8"

# (list) Gradle repositories to add {can be necessary for some android.gradle_dependencies}
# please enclose in double quotes 
# e.g. android.gradle_repositories = "maven { url 'https://kotlin.bintray.com/ktor' }"
#android.add_gradle_repositories =

# (list) packaging options to add 
# see https://google.github.io/android-gradle-dsl/current/com.android.build.gradle.internal.dsl.PackagingOptions.html
# can be necessary to solve conflicts in gradle_dependencies
# please enclose in double quotes 
# e.g. android.add_packaging_options = "exclude 'META-INF/common.kotlin_module'", "exclude 'META-INF/*.kotlin_module'"
#android.add_packaging_options =

# (list) Java classes to add as activities to the manifest.
#android.add_activities = com.example.ExampleActivity

# (str) OUYA Console category. Should be one of GAME or APP
# If you leave this blank, OUYA support will not be enabled
#android.ouya.category = GAME

# (str) Filename of OUYA Console icon. It must be a 732x412 png image.
#android.ouya.icon.filename = %(source.dir)s/data/ouya_icon.png

# (str) XML file to include as an intent filters in <activity> tag
#android.manifest.intent_filters =

# (list) Copy these files to src/main/res/xml/ (used for example with intent-filters)
#android.res_xml = PATH_TO_FILE,

# (str) launchMode to set for the main activity
#android.manifest.launch_mode = standard

# (str) screenOrientation to set for the main activity.
# Valid values can be found at https://developer.android.com/guide/topics/manifest/activity-element
#android.manifest.orientation = fullSensor

# (list) Android additional libraries to copy into libs/armeabi
#android.add_libs_armeabi = libs/android/*.so
#android.add_libs_armeabi_v7a = libs/android-v7/*.so
#android.add_libs_arm64_v8a = libs/android-v8/*.so
#android.add_libs_x86 = libs/android-x86/*.so
#android.add_libs_mips = libs/android-mips/*.so

# (bool) Indicate whether the screen should stay on
# Don't forget to add the WAKE_LOCK permission if you set this to True
#android.wakelock = False

# (list) Android application meta-data to set (key=value format)
#android.meta_data =

# (list) Android library project to add (will be added in the
# project.properties automatically.)
#android.library_references =

# (list) Android shared libraries which will be added to AndroidManifest.xml using <uses-library> tag
#android.uses_library =

# (str) Android logcat filters to use
#android.logcat_filters = *:S python:D

# (bool) Android logcat only display log for activity's pid
#android.logcat_pid_only = False

# (str) Android additional adb arguments
#android.adb_args = -H host.docker.internal

# (bool) Copy library instead of making a libpymodules.so
#android.copy_libs = 1

# (list) The Android archs to build for, choices: armeabi-v7a, arm64-v8a, x86, x86_64
# In past, was `android.arch` as we weren't supporting builds for multiple archs at the same time.
android.archs = arm64-v8a, armeabi-v7a

# (int) overrides automatic versionCode computation (used in build.gradle)
# this is not the same as app version and should only be edited if you know what you're doing
# android.numeric_version = 1

# (bool) enables Android auto backup feature (Android API >=23)
android.allow_backup = True

# (str) XML file for custom backup rules (see official auto backup documentation)
# android.backup_rules =

# (str) If you need to insert variables into your AndroidManifest.xml file,
# you can do so with the manifestPlaceholders property.
# This property takes a map of key-value pairs. (via a string)
# Usage example : android.manifest_placeholders = [myCustomUrl:\"org.kivy.customurl\"]
# android.manifest_placeholders = [:]

# (bool) Skip byte compile for .py files
# android.no-byte-compile-python = False

# (str) The format used to package the app for release mode (aab or apk or aar).
# android.release_artifact = aab

# (str) The format used to package the app for debug mode (apk or aar).
# android.debug_artifact = apk

#
# Python for android (p4a) specific
#

# (str) python-for-android URL to use for checkout
#p4a.url =

# (str) python-for-android fork to use in case if p4a.url is not specified, defaults to upstream (kivy)
#p4a.fork = kivy

# (str) python-for-android branch to use, defaults to master
p4a.branch = master

# (str) python-for-android specific commit to use, defaults to HEAD, must be within p4a.branch
#p4a.commit = HEAD

# (str) python-for-android git clone directory (if empty, it will be automatically cloned from github)
#p4a.source_dir =

# (str) The directory in which python-for-android should look for your own build recipes (if any)
#p4a.local_recipes =

# (str) Filename to the hook for p4a
#p4a.hook =

# (str) Bootstrap to use for android builds
# p4a.bootstrap = sdl2

# (int) port number to specify an explicit --port= p4a argument (eg for bootstrap flask)
#p4a.port =

# Control passing the --use-setup-py vs --ignore-setup-py to p4a
# "in the future" --use-setup-py is going to be the default behaviour in p4a, right now it is not
# Setting this to false will pass --ignore-setup-py, true will pass --use-setup-py
# NOTE: this is general setuptools integration, having pyproject.toml is enough, no need to generate
# setup.py if you're using Poetry, but you need to add "toml" to source.include_exts.
#p4a.setup_py = false

# (str) extra command line arguments to pass when invoking pythonforandroid.toolchain
#p4a.extra_args =



#
# iOS specific
#

# (str) Path to a custom kivy-ios folder
#ios.kivy_ios_dir = ../kivy-ios
# Alternately, specify the URL and branch of a git checkout:
ios.kivy_ios_url = https://github.com/kivy/kivy-ios
ios.kivy_ios_branch = master

# Another platform dependency: ios-deploy
# Uncomment to use a custom checkout
#ios.ios_deploy_dir = ../ios_deploy
# Or specify URL and branch
ios.ios_deploy_url = https://github.com/phonegap/ios-deploy
ios.ios_deploy_branch = 1.10.0

# (bool) Whether or not to sign the code
ios.codesign.allowed = false

# (str) Name of the certificate to use for signing the debug version
# Get a list of available identities: buildozer ios list_identities
#ios.codesign.debug = "iPhone Developer: <lastname> <firstname> (<hexstring>)"

# (str) The development team to use for signing the debug version
#ios.codesign.development_team.debug = <hexstring>

# (str) Name of the certificate to use for signing the release version
#ios.codesign.release = %(ios.codesign.debug)s

# (str) The development team to use for signing the release version
#ios.codesign.development_team.release = <hexstring>

# (str) URL pointing to .ipa file to be installed
# This option should be defined along with `display_image_url` and `full_size_image_url` options.
#ios.manifest.app_url =

# (str) URL pointing to an icon (57x57px) to be displayed during download
# This option should be defined along with `app_url` and `full_size_image_url` options.
#ios.manifest.display_image_url =

# (str) URL pointing to a large icon (512x512px) to be used by iTunes
# This option should be defined along with `app_url` and `display_image_url` options.
#ios.manifest.full_size_image_url =


[buildozer]

# (int) Log level (0 = error only, 1 = info, 2 = debug (with command output))
log_level = 2

# (int) Display warning if buildozer is run as root (0 = False, 1 = True)
warn_on_root = 1

# (str) Path to build artifact storage, absolute or relative to spec file
# build_dir = ./.buildozer

# (str) Path to build output (i.e. .apk, .aab, .ipa) storage
# bin_dir = ./bin

#    -----------------------------------------------------------------------------
#    List as sections
#
#    You can define all the "list" as [section:key].
#    Each line will be considered as a option to the list.
#    Let's take [app] / source.exclude_patterns.
#    Instead of doing:
#
#[app]
#source.exclude_patterns = license,data/audio/*.wav,data/images/original/*
#
#    This can be translated into:
#
#[app:source.exclude_patterns]
#license
#data/audio/*.wav
#data/images/original/*
#


#    -----------------------------------------------------------------------------
#    Profiles
#
#    You can extend section / key with a profile
#    For example, you want to deploy a demo version of your application without
#    HD content. You could first change the title to add "(demo)" in the name
#    and extend the excluded directories to remove the HD content.
#
#[app@demo]
#title = My Application (demo)
#
#[app:source.exclude_patterns@demo]
#images/hd/*
#
#    Then, invoke the command line with the "demo" profile:
#
#buildozer --profile demo android debug

main.py

python 复制代码
#!/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()
相关推荐
多语种客服外包1 小时前
智能宠物饮水机跨境经营:售后建设对海外业务收益的现实影响
网络·宠物
CHENKONG_CK1 小时前
恶劣工况下 RFID 赋能喷涂产线自动化分拣与作业
运维·网络·人工智能·自动化·汽车·rfid·rfid
北京中科新远科技1 小时前
AI集群交换网络容量怎么算:端口、收敛比与Leaf-Spine验收
服务器·网络·人工智能
白帽攻防录1 小时前
SRC 挖洞:GitLab GraphQL 指令绕过深度复盘,CVE-2026-19478 未授权删项目怎么打穿代码托管平台
网络·网络安全·gitlab·graphql
neo_will_mvp1 小时前
上海莫双一流211大学:实操安装HP1188pnw网络打印机
网络
Johny_Zhao10 小时前
FreeRADIUS + 华为AC/AP + 802.1X/WPA2-Enterprise 无线认证对接部署完整指南
网络·网络安全·信息安全·ldap·radius·huawei·系统运维·centos8
天远API11 小时前
零信任架构实战:基于天远人企关联构建自动化图谱网关
网络·人工智能·架构·自动化
vipjx112 小时前
百度网盘限速怎么破?2026实测PanDownload多线程提速方案
网络·智能路由器
不会就选b14 小时前
Linux之应用层协议HTTP(二)
网络·网络协议·http