1671 lines
57 KiB
Python
1671 lines
57 KiB
Python
#!/usr/bin/env python3
|
||
"""跨平台媒体播放器(Windows / Linux / macOS)。
|
||
|
||
基于 tkinter;播放内核按优先级为:VLC(python-vlc)、纯 Python 解码
|
||
(PyAV + sounddevice,无需安装任何外部软件)、pygame 音频回退。
|
||
|
||
用法:
|
||
python media_player.py [媒体文件或文件夹...]
|
||
|
||
常用快捷键:
|
||
Space 播放 / 暂停
|
||
← / → 后退 / 前进 10 秒
|
||
Shift+← / → 后退 / 前进 60 秒
|
||
↑ / ↓ 音量 +5 / -5
|
||
Ctrl+↑ / ↓ 快进步长 +1 / -1 秒(macOS 亦支持 Command+↑ / ↓)
|
||
勾选“按句跳转”后,←/→ 自动播放上一句/下一句(基于音频静音识别句边界)
|
||
M 静音 / 恢复
|
||
Ctrl+← / → 上一首 / 下一首(macOS 也支持 Command+← / →)
|
||
N / P 下一首 / 上一首
|
||
[ / ] 减速 / 加速(0.5x - 2.0x)
|
||
L 切换循环模式(关 / 单曲 / 列表)
|
||
F 视频全屏切换
|
||
Esc 退出全屏
|
||
Ctrl+O 打开文件(macOS 也支持 Command+O)
|
||
Delete 从播放列表移除所选曲目
|
||
Enter 播放列表中选中的曲目
|
||
"""
|
||
|
||
import collections
|
||
import json
|
||
import os
|
||
import queue
|
||
import sys
|
||
import threading
|
||
import time
|
||
from pathlib import Path
|
||
|
||
import tkinter as tk
|
||
from tkinter import filedialog, messagebox, ttk
|
||
|
||
try:
|
||
from PIL import Image as PILImage, ImageTk
|
||
except ImportError:
|
||
PILImage = ImageTk = None
|
||
|
||
try:
|
||
import vlc
|
||
except Exception:
|
||
vlc = None
|
||
|
||
try:
|
||
import pygame
|
||
except ImportError:
|
||
pygame = None
|
||
|
||
IS_MAC = sys.platform == "darwin"
|
||
IS_WIN = sys.platform == "win32"
|
||
IS_LINUX = sys.platform.startswith("linux")
|
||
|
||
AUDIO_EXTS = {
|
||
".mp3", ".wav", ".ogg", ".flac", ".m4a", ".aac",
|
||
".opus", ".wma", ".aiff", ".ape",
|
||
}
|
||
VIDEO_EXTS = {
|
||
".mp4", ".mkv", ".avi", ".mov", ".wmv", ".flv",
|
||
".webm", ".m4v", ".ts", ".mpg", ".mpeg", ".3gp",
|
||
}
|
||
SUPPORTED_EXTS = AUDIO_EXTS | VIDEO_EXTS
|
||
|
||
LOOP_LABELS = {"off": "循环: 关", "one": "循环: 单曲", "list": "循环: 列表"}
|
||
SPEED_PRESETS = [0.5, 0.75, 1.0, 1.25, 1.5, 2.0]
|
||
SEEK_STEP = 10
|
||
SEEK_STEP_BIG = 60
|
||
SILENCE_FLOOR_PERCENTILE = 10 # 用能量低分位估计背景噪声底
|
||
SILENCE_THRESHOLD_FACTOR = 1.5 # 静音阈值 = 噪声底 × 系数
|
||
SILENCE_BRIDGE_GAP = 0.06 # 停顿中 60ms 内的短促残响/噪声也按静音处理
|
||
|
||
|
||
def fmt_time(ms):
|
||
"""把毫秒格式化为 mm:ss 或 h:mm:ss。"""
|
||
if ms is None or ms < 0:
|
||
return "--:--"
|
||
total = int(ms // 1000)
|
||
hours, remainder = divmod(total, 3600)
|
||
minutes, seconds = divmod(remainder, 60)
|
||
if hours:
|
||
return f"{hours}:{minutes:02d}:{seconds:02d}"
|
||
return f"{minutes:02d}:{seconds:02d}"
|
||
|
||
|
||
def detect_sentence_boundaries(path, min_silence=0.30, min_sentence=0.35):
|
||
"""基于音频静音自动切分句子,返回每句话开始的时间(秒)列表。
|
||
|
||
语音之间通常有短暂停顿,把停顿前后的连续声音视为一句话。
|
||
没有音频轨、无法解码或缺少依赖时返回 None。
|
||
"""
|
||
try:
|
||
import av
|
||
import numpy as np
|
||
except ImportError:
|
||
return None
|
||
try:
|
||
container = av.open(str(path))
|
||
audio = next((s for s in container.streams if s.type == "audio"), None)
|
||
if audio is None:
|
||
container.close()
|
||
return None
|
||
sample_rate = 16000
|
||
resampler = av.AudioResampler(format="fltp", layout="mono", rate=sample_rate)
|
||
window = 480 # 30ms @ 16kHz
|
||
frame_seconds = window / sample_rate
|
||
energies = []
|
||
buf = []
|
||
|
||
def push(data):
|
||
buf.append(data)
|
||
total = sum(len(x) for x in buf)
|
||
if total < window:
|
||
return
|
||
arr = np.concatenate(buf)
|
||
buf.clear()
|
||
n = len(arr) // window * window
|
||
win = arr[:n].reshape(-1, window)
|
||
energies.extend((np.mean(win * win, axis=1) ** 0.5).tolist())
|
||
if len(arr) > n:
|
||
buf.append(arr[n:])
|
||
|
||
for packet in container.demux(audio):
|
||
for frame in packet.decode():
|
||
for out in resampler.resample(frame):
|
||
push(out.to_ndarray()[0])
|
||
for out in resampler.resample(None):
|
||
push(out.to_ndarray()[0])
|
||
if buf:
|
||
push(np.zeros(window, dtype=np.float32))
|
||
duration = container.duration
|
||
container.close()
|
||
if len(energies) < 3:
|
||
return None
|
||
energies = np.asarray(energies, dtype=np.float64)
|
||
floor = float(np.percentile(energies, 95))
|
||
if floor <= 0.0:
|
||
return None
|
||
noise_floor = float(np.percentile(energies, SILENCE_FLOOR_PERCENTILE))
|
||
threshold = max(0.008, SILENCE_THRESHOLD_FACTOR * noise_floor)
|
||
silence = energies < threshold
|
||
bridge_frames = int(round(SILENCE_BRIDGE_GAP / frame_seconds))
|
||
if bridge_frames > 0:
|
||
bridged = silence.copy()
|
||
run_start = None
|
||
for idx, is_silent in enumerate(silence):
|
||
if not is_silent and run_start is None:
|
||
run_start = idx
|
||
elif is_silent and run_start is not None:
|
||
if run_start > 0 and idx - run_start <= bridge_frames:
|
||
bridged[run_start:idx] = True
|
||
run_start = None
|
||
silence = bridged
|
||
boundaries = [0.0]
|
||
run_start = None
|
||
for idx, is_silent in enumerate(silence):
|
||
if is_silent and run_start is None:
|
||
run_start = idx
|
||
elif not is_silent and run_start is not None:
|
||
if (idx - run_start) * frame_seconds >= min_silence:
|
||
end_sec = idx * frame_seconds
|
||
if end_sec - boundaries[-1] >= min_sentence:
|
||
boundaries.append(end_sec)
|
||
run_start = None
|
||
if run_start is not None and (len(silence) - run_start) * frame_seconds >= min_silence:
|
||
end_sec = len(silence) * frame_seconds
|
||
if end_sec - boundaries[-1] >= min_sentence:
|
||
boundaries.append(end_sec)
|
||
if duration and duration > 0:
|
||
boundaries = [b for b in boundaries if b < duration / 1e6 - 0.1]
|
||
return boundaries
|
||
except Exception:
|
||
return None
|
||
|
||
|
||
class _BaseEngine:
|
||
"""播放内核统一接口。"""
|
||
|
||
EMBEDDABLE = False
|
||
supports_rate = True
|
||
|
||
def __init__(self):
|
||
self._events = queue.Queue()
|
||
|
||
def play(self, path):
|
||
raise NotImplementedError
|
||
|
||
def toggle_pause(self):
|
||
pass
|
||
|
||
def stop(self):
|
||
pass
|
||
|
||
def seek(self, ms):
|
||
return False
|
||
|
||
def get_time(self):
|
||
return -1
|
||
|
||
def get_length(self):
|
||
return -1
|
||
|
||
def is_active(self):
|
||
return False
|
||
|
||
def is_playing(self):
|
||
return False
|
||
|
||
def set_volume(self, volume):
|
||
pass
|
||
|
||
def set_rate(self, rate):
|
||
pass
|
||
|
||
def set_fullscreen(self, on):
|
||
pass
|
||
|
||
def embed(self, window_id):
|
||
return False
|
||
|
||
def get_frame(self):
|
||
"""返回当前应显示的帧(PIL 图像),没有新帧时返回 None。"""
|
||
return None
|
||
|
||
def poll_events(self):
|
||
events = []
|
||
while True:
|
||
try:
|
||
events.append(self._events.get_nowait())
|
||
except queue.Empty:
|
||
return events
|
||
|
||
def release(self):
|
||
pass
|
||
|
||
|
||
class VlcEngine(_BaseEngine):
|
||
"""基于 VLC 的内核:支持音视频、定位、变速与全屏。"""
|
||
|
||
EMBEDDABLE = not IS_MAC
|
||
|
||
def __init__(self):
|
||
super().__init__()
|
||
args = ["--no-video-title-show", "--quiet"]
|
||
if IS_MAC:
|
||
args.append("--vout=macosx")
|
||
self.instance = vlc.Instance(args)
|
||
self.player = self.instance.media_player_new()
|
||
self._media = None
|
||
self._rate = 1.0
|
||
self._volume = 80
|
||
manager = self.player.event_manager()
|
||
manager.event_attach(vlc.EventType.MediaPlayerEndReached, self._on_event_end)
|
||
manager.event_attach(vlc.EventType.MediaPlayerEncounteredError, self._on_event_error)
|
||
|
||
def _on_event_end(self, _event):
|
||
self._events.put("end")
|
||
|
||
def _on_event_error(self, _event):
|
||
self._events.put("error")
|
||
|
||
def play(self, path):
|
||
media = self.instance.media_new(str(path))
|
||
self.player.set_media(media)
|
||
self._media = media
|
||
self.player.play()
|
||
self.player.set_rate(self._rate)
|
||
self.player.audio_set_volume(self._volume)
|
||
return True
|
||
|
||
def toggle_pause(self):
|
||
if not self.is_active():
|
||
return
|
||
if self.is_playing():
|
||
self.player.set_pause(1)
|
||
else:
|
||
self.player.set_pause(0)
|
||
|
||
def stop(self):
|
||
self.player.stop()
|
||
self._media = None
|
||
|
||
def seek(self, ms):
|
||
if not self.is_active():
|
||
return False
|
||
self.player.set_time(max(int(ms), 0))
|
||
return True
|
||
|
||
def get_time(self):
|
||
return self.player.get_time() if self.is_active() else -1
|
||
|
||
def get_length(self):
|
||
if not self.is_active():
|
||
return -1
|
||
length = self.player.get_length()
|
||
return length if length > 0 else -1
|
||
|
||
def is_active(self):
|
||
return self._media is not None
|
||
|
||
def is_playing(self):
|
||
return bool(self.player.is_playing())
|
||
|
||
def set_volume(self, volume):
|
||
self._volume = max(0, min(100, int(volume)))
|
||
self.player.audio_set_volume(self._volume)
|
||
|
||
def set_rate(self, rate):
|
||
self._rate = float(rate)
|
||
self.player.set_rate(self._rate)
|
||
|
||
def set_fullscreen(self, on):
|
||
self.player.set_fullscreen(bool(on))
|
||
|
||
def embed(self, window_id):
|
||
if IS_WIN:
|
||
self.player.set_hwnd(int(window_id))
|
||
elif IS_LINUX:
|
||
self.player.set_xwindow(int(window_id))
|
||
return True
|
||
|
||
def release(self):
|
||
try:
|
||
self.player.stop()
|
||
self.player.release()
|
||
except Exception:
|
||
pass
|
||
|
||
|
||
class PygameEngine(_BaseEngine):
|
||
"""基于 pygame 的回退内核:无 VLC 时播放音频文件。"""
|
||
|
||
EMBEDDABLE = False
|
||
supports_rate = False
|
||
|
||
def __init__(self):
|
||
super().__init__()
|
||
pygame.mixer.init()
|
||
self._volume = 80
|
||
self._paused = False
|
||
self._started = False
|
||
self._stopped = True
|
||
self._pos = 0
|
||
self._end_reported = False
|
||
|
||
def play(self, path):
|
||
self.stop()
|
||
pygame.mixer.music.load(str(path))
|
||
pygame.mixer.music.play()
|
||
pygame.mixer.music.set_volume(self._volume / 100.0)
|
||
self._started = True
|
||
self._stopped = False
|
||
self._paused = False
|
||
self._pos = 0
|
||
self._end_reported = False
|
||
return True
|
||
|
||
def toggle_pause(self):
|
||
if self._stopped:
|
||
return
|
||
if self._paused:
|
||
pygame.mixer.music.unpause()
|
||
self._paused = False
|
||
else:
|
||
pygame.mixer.music.pause()
|
||
self._paused = True
|
||
|
||
def stop(self):
|
||
pygame.mixer.music.stop()
|
||
self._started = False
|
||
self._stopped = True
|
||
self._paused = False
|
||
self._pos = 0
|
||
|
||
def seek(self, ms):
|
||
try:
|
||
pygame.mixer.music.set_pos(max(ms, 0) / 1000.0)
|
||
self._pos = max(ms, 0)
|
||
return True
|
||
except pygame.error:
|
||
return False
|
||
|
||
def get_time(self):
|
||
if self._stopped:
|
||
return -1
|
||
if not self._paused and pygame.mixer.music.get_busy():
|
||
pos = pygame.mixer.music.get_pos()
|
||
if pos > 0:
|
||
self._pos = pos
|
||
return int(self._pos)
|
||
|
||
def get_length(self):
|
||
return -1
|
||
|
||
def is_active(self):
|
||
return self._started
|
||
|
||
def is_playing(self):
|
||
return self._started and not self._paused and pygame.mixer.music.get_busy()
|
||
|
||
def set_volume(self, volume):
|
||
self._volume = max(0, min(100, int(volume)))
|
||
pygame.mixer.music.set_volume(self._volume / 100.0)
|
||
|
||
def set_rate(self, rate):
|
||
pass
|
||
|
||
def set_fullscreen(self, on):
|
||
pass
|
||
|
||
def poll_events(self):
|
||
events = super().poll_events()
|
||
if (
|
||
self._started
|
||
and not self._paused
|
||
and not self._stopped
|
||
and not pygame.mixer.music.get_busy()
|
||
and not self._end_reported
|
||
):
|
||
self._end_reported = True
|
||
events.append("end")
|
||
return events
|
||
|
||
def release(self):
|
||
try:
|
||
pygame.mixer.music.stop()
|
||
pygame.mixer.quit()
|
||
except Exception:
|
||
pass
|
||
|
||
|
||
class AvEngine(_BaseEngine):
|
||
"""纯 Python 解码内核:基于 PyAV 与 sounddevice,无需安装 VLC。
|
||
|
||
跨平台(Windows / macOS / Linux)可用:PyAV 自带 FFmpeg 解码能力,
|
||
sounddevice 自带 PortAudio 音频输出,视频画面由主窗口负责绘制。
|
||
"""
|
||
|
||
EMBEDDABLE = True
|
||
supports_rate = False
|
||
|
||
AUDIO_RATE = 44100
|
||
|
||
def __init__(self):
|
||
super().__init__()
|
||
try:
|
||
import av
|
||
import numpy # noqa: F401 to_ndarray 依赖
|
||
import sounddevice as sd
|
||
from PIL import Image # noqa: F401
|
||
except ImportError as exc:
|
||
raise ImportError("缺少纯 Python 播放依赖(av / sounddevice / numpy / Pillow):%s" % exc)
|
||
self._av = av
|
||
self._sd = sd
|
||
self._lock = threading.Lock()
|
||
self._reset_state()
|
||
|
||
def _reset_state(self):
|
||
self._container = None
|
||
self._video_stream = None
|
||
self._audio_stream = None
|
||
self._video_deque = collections.deque()
|
||
self._video_deque_max = 6
|
||
self._audio_queue = queue.Queue(maxsize=64)
|
||
self._audio_leftover = None
|
||
self._audio_stream_obj = None
|
||
self._audio_ok = False
|
||
self._audio_broken = False
|
||
self._audio_written = 0
|
||
self._resampler = None
|
||
self._thread = None
|
||
self._stop_flag = threading.Event()
|
||
self._seek_request = None
|
||
self._gen = 0
|
||
self._seek_after = 0.0
|
||
self._eof = False
|
||
self._ended = False
|
||
self._paused = False
|
||
self._volume = 80
|
||
self._clock_base = 0.0
|
||
self._clock_started = time.monotonic()
|
||
self._clock_running = False
|
||
|
||
def play(self, path):
|
||
self.stop()
|
||
container = self._av.open(str(path))
|
||
self._container = container
|
||
videos = [s for s in container.streams if s.type == "video"]
|
||
if videos:
|
||
from av.stream import Disposition
|
||
|
||
self._video_stream = max(
|
||
videos,
|
||
key=lambda s: (0 if s.disposition & Disposition.attached_pic else 1, s.frames or 0),
|
||
)
|
||
else:
|
||
self._video_stream = None
|
||
self._audio_stream = next((s for s in container.streams if s.type == "audio"), None)
|
||
self._gen += 1
|
||
self._eof = False
|
||
self._ended = False
|
||
self._paused = False
|
||
self._seek_after = 0.0
|
||
self._resampler = None
|
||
self._audio_ok = False
|
||
self._audio_broken = False
|
||
if self._audio_stream is not None:
|
||
try:
|
||
self._audio_stream_obj = self._sd.OutputStream(
|
||
samplerate=self.AUDIO_RATE,
|
||
channels=2,
|
||
dtype="float32",
|
||
callback=self._audio_callback,
|
||
)
|
||
self._audio_stream_obj.start()
|
||
self._audio_ok = True
|
||
except Exception:
|
||
self._audio_stream_obj = None
|
||
self._audio_written = 0
|
||
self._clock_base = 0.0
|
||
self._clock_started = time.monotonic()
|
||
self._clock_running = self._audio_stream_obj is None
|
||
self._stop_flag = threading.Event()
|
||
self._thread = threading.Thread(target=self._decode_loop, daemon=True)
|
||
self._thread.start()
|
||
return True
|
||
|
||
def toggle_pause(self):
|
||
if not self.is_active() or self._ended:
|
||
return
|
||
self._paused = not self._paused
|
||
if self._paused:
|
||
if self._clock_running:
|
||
self._clock_base = self._noaudio_pos()
|
||
self._clock_running = False
|
||
else:
|
||
self._clock_started = time.monotonic()
|
||
if self._audio_stream_obj is None:
|
||
self._clock_running = True
|
||
|
||
def stop(self):
|
||
self._stop_flag.set()
|
||
if self._thread is not None:
|
||
self._thread.join(timeout=1.0)
|
||
if self._audio_stream_obj is not None:
|
||
try:
|
||
self._audio_stream_obj.stop()
|
||
self._audio_stream_obj.close()
|
||
except Exception:
|
||
pass
|
||
if self._container is not None:
|
||
try:
|
||
self._container.close()
|
||
except Exception:
|
||
pass
|
||
self._reset_state()
|
||
|
||
def seek(self, ms):
|
||
if not self.is_active():
|
||
return False
|
||
target = max(0.0, int(ms) / 1000.0)
|
||
with self._lock:
|
||
self._seek_request = target
|
||
self._seek_after = target
|
||
self._gen += 1
|
||
self._video_deque.clear()
|
||
self._drain_queue(self._audio_queue)
|
||
self._audio_leftover = None
|
||
self._audio_written = int(target * self.AUDIO_RATE)
|
||
self._clock_base = target
|
||
self._clock_started = time.monotonic()
|
||
self._ended = False
|
||
if self._audio_stream_obj is not None:
|
||
try:
|
||
self._audio_stream_obj.stop()
|
||
self._audio_stream_obj.start()
|
||
except Exception:
|
||
pass
|
||
if self._thread is None or not self._thread.is_alive():
|
||
self._eof = False
|
||
self._resampler = None
|
||
self._stop_flag = threading.Event()
|
||
self._thread = threading.Thread(target=self._decode_loop, daemon=True)
|
||
self._thread.start()
|
||
return True
|
||
|
||
def get_time(self):
|
||
if not self.is_active():
|
||
return -1
|
||
if self._audio_stream_obj is not None:
|
||
with self._lock:
|
||
return int(self._audio_written * 1000.0 / self.AUDIO_RATE)
|
||
return int(self._noaudio_pos() * 1000.0)
|
||
|
||
def _disable_audio(self, reason=""):
|
||
"""音频不可用时降级为无声播放,视频播放不受影响。"""
|
||
stream = self._audio_stream_obj
|
||
self._audio_stream_obj = None
|
||
self._audio_ok = False
|
||
self._audio_broken = False
|
||
self._drain_queue(self._audio_queue)
|
||
self._audio_leftover = None
|
||
if stream is not None:
|
||
try:
|
||
stream.stop()
|
||
stream.close()
|
||
except Exception:
|
||
pass
|
||
with self._lock:
|
||
self._clock_base = self._audio_written / self.AUDIO_RATE
|
||
self._clock_started = time.monotonic()
|
||
self._clock_running = not self._paused
|
||
if reason:
|
||
print("音频不可用,已切换为无声播放:", reason)
|
||
|
||
def _noaudio_pos(self):
|
||
if self._clock_running:
|
||
return self._clock_base + (time.monotonic() - self._clock_started)
|
||
return self._clock_base
|
||
|
||
def get_length(self):
|
||
if not self.is_active():
|
||
return -1
|
||
duration = self._container.duration
|
||
if duration and duration > 0:
|
||
return int(duration / 1000)
|
||
return -1
|
||
|
||
def is_active(self):
|
||
return self._container is not None
|
||
|
||
def is_playing(self):
|
||
return self.is_active() and not self._paused and not self._ended
|
||
|
||
def set_volume(self, volume):
|
||
self._volume = max(0, min(100, int(volume)))
|
||
|
||
def set_rate(self, rate):
|
||
pass
|
||
|
||
def set_fullscreen(self, on):
|
||
pass
|
||
|
||
def embed(self, window_id):
|
||
return False
|
||
|
||
def get_frame(self):
|
||
if not self.is_active():
|
||
return None
|
||
pos = self.get_time() / 1000.0
|
||
latest = None
|
||
with self._lock:
|
||
while self._video_deque:
|
||
gen, secs, img = self._video_deque[0]
|
||
if gen != self._gen:
|
||
self._video_deque.popleft()
|
||
continue
|
||
if secs > pos + 0.05:
|
||
break
|
||
latest = img
|
||
self._video_deque.popleft()
|
||
return latest
|
||
|
||
def poll_events(self):
|
||
events = super().poll_events()
|
||
if self._audio_broken and self._audio_stream_obj is not None:
|
||
self._disable_audio("音频回调异常")
|
||
if not self._ended and self.is_active() and not self._paused and self._eof:
|
||
with self._lock:
|
||
video_done = not self._video_deque
|
||
audio_done = (
|
||
self._audio_stream_obj is None
|
||
or (self._audio_queue.empty() and self._audio_leftover is None)
|
||
)
|
||
if video_done and audio_done:
|
||
self._ended = True
|
||
events.append("end")
|
||
return events
|
||
|
||
def release(self):
|
||
self.stop()
|
||
|
||
def _decode_loop(self):
|
||
try:
|
||
container = self._container
|
||
video = self._video_stream
|
||
audio = self._audio_stream
|
||
streams = [s for s in (video, audio) if s is not None]
|
||
demux = container.demux(*streams)
|
||
while not self._stop_flag.is_set():
|
||
if self._seek_request is not None:
|
||
with self._lock:
|
||
target = self._seek_request
|
||
self._seek_request = None
|
||
seek_stream = video if video is not None else audio
|
||
if seek_stream is not None:
|
||
offset = int(round(target / float(seek_stream.time_base)))
|
||
try:
|
||
container.seek(offset, stream=seek_stream)
|
||
except Exception:
|
||
pass
|
||
demux = container.demux(*streams)
|
||
self._resampler = None
|
||
continue
|
||
gen = self._gen
|
||
try:
|
||
packet = next(demux)
|
||
except StopIteration:
|
||
self._eof = True
|
||
break
|
||
except Exception:
|
||
self._eof = True
|
||
break
|
||
if self._stop_flag.is_set():
|
||
break
|
||
if packet.stream.type == "video" and video is not None and packet.size:
|
||
try:
|
||
frames = packet.decode()
|
||
except Exception:
|
||
continue
|
||
for frame in frames:
|
||
if self._stop_flag.is_set():
|
||
break
|
||
if frame.pts is None:
|
||
continue
|
||
secs = float(frame.pts * video.time_base)
|
||
if secs + 0.05 < self._seek_after:
|
||
continue
|
||
try:
|
||
img = frame.to_image()
|
||
except Exception:
|
||
continue
|
||
while not self._stop_flag.is_set():
|
||
with self._lock:
|
||
if len(self._video_deque) < self._video_deque_max:
|
||
self._video_deque.append((gen, secs, img))
|
||
break
|
||
time.sleep(0.005)
|
||
elif packet.stream.type == "audio" and audio is not None and packet.size:
|
||
if not self._audio_ok:
|
||
continue
|
||
try:
|
||
self._decode_audio_packet(packet, gen)
|
||
except Exception as exc:
|
||
self._disable_audio("音频解码出错(%s)" % exc)
|
||
if self._resampler is not None and not self._stop_flag.is_set():
|
||
self._flush_resampler()
|
||
except Exception as exc:
|
||
self._eof = True
|
||
print("解码线程异常退出:", exc)
|
||
|
||
def _decode_audio_packet(self, packet, gen):
|
||
try:
|
||
frames = packet.decode()
|
||
except Exception:
|
||
return
|
||
atb = self._audio_stream.time_base
|
||
for frame in frames:
|
||
if self._stop_flag.is_set():
|
||
return
|
||
if frame.pts is not None:
|
||
secs = float(frame.pts * atb)
|
||
if secs + 0.05 < self._seek_after:
|
||
continue
|
||
if self._resampler is None:
|
||
self._resampler = self._av.AudioResampler(
|
||
format="fltp", layout="stereo", rate=self.AUDIO_RATE
|
||
)
|
||
for out in self._resampler.resample(frame):
|
||
try:
|
||
chunk = out.to_ndarray().T
|
||
except Exception:
|
||
continue
|
||
self._put_audio((gen, chunk))
|
||
|
||
def _put_audio(self, item):
|
||
while not self._stop_flag.is_set():
|
||
try:
|
||
self._audio_queue.put(item, timeout=0.1)
|
||
return
|
||
except queue.Full:
|
||
if item[0] != self._gen:
|
||
return
|
||
|
||
def _flush_resampler(self):
|
||
try:
|
||
for out in self._resampler.resample(None):
|
||
self._put_audio((self._gen, out.to_ndarray().T))
|
||
except Exception:
|
||
pass
|
||
|
||
def _audio_callback(self, outdata, frames, time_info, status):
|
||
try:
|
||
self._audio_callback_inner(outdata, frames)
|
||
except Exception:
|
||
self._audio_broken = True
|
||
outdata[:] = 0
|
||
|
||
def _audio_callback_inner(self, outdata, frames):
|
||
if self._paused:
|
||
outdata[:] = 0
|
||
return
|
||
outdata[:] = 0
|
||
n = 0
|
||
leftover = self._audio_leftover
|
||
if leftover is not None and len(leftover):
|
||
take = min(len(leftover), frames)
|
||
outdata[:take] = leftover[:take]
|
||
n = take
|
||
self._audio_leftover = leftover[take:] if take < len(leftover) else None
|
||
while n < frames:
|
||
try:
|
||
item = self._audio_queue.get_nowait()
|
||
except queue.Empty:
|
||
break
|
||
gen, chunk = item
|
||
if gen != self._gen:
|
||
continue
|
||
take = min(len(chunk), frames - n)
|
||
outdata[n:n + take] = chunk[:take]
|
||
n += take
|
||
if take < len(chunk):
|
||
self._audio_leftover = chunk[take:]
|
||
outdata *= self._volume / 100.0
|
||
with self._lock:
|
||
self._audio_written += frames
|
||
|
||
@staticmethod
|
||
def _drain_queue(q):
|
||
while True:
|
||
try:
|
||
q.get_nowait()
|
||
except queue.Empty:
|
||
return
|
||
|
||
|
||
class SeekBar(tk.Canvas):
|
||
"""自绘进度条:支持点击与拖动定位,跨平台表现一致。"""
|
||
|
||
TRACK_COLOR = "#4a4a4a"
|
||
FILL_COLOR = "#3f8efc"
|
||
|
||
def __init__(self, master, height=24, command=None):
|
||
super().__init__(
|
||
master,
|
||
height=height,
|
||
bg="#2b2b2b",
|
||
highlightthickness=0,
|
||
cursor="hand2",
|
||
)
|
||
self._command = command
|
||
self._frac = 0.0
|
||
self._dragging = False
|
||
self.bind("<Configure>", lambda _e: self._redraw())
|
||
self.bind("<ButtonPress-1>", self._on_press)
|
||
self.bind("<B1-Motion>", self._on_drag)
|
||
self.bind("<ButtonRelease-1>", self._on_release)
|
||
self._redraw()
|
||
|
||
def _width(self):
|
||
return max(self.winfo_width(), 1)
|
||
|
||
def _x_of_frac(self, frac):
|
||
return 4 + frac * (self._width() - 8)
|
||
|
||
def _frac_of_event(self, event):
|
||
return min(max((event.x - 4) / max(self._width() - 8, 1), 0.0), 1.0)
|
||
|
||
def set_frac(self, frac):
|
||
if not self._dragging:
|
||
self._frac = min(max(frac, 0.0), 1.0)
|
||
self._redraw()
|
||
|
||
def _redraw(self):
|
||
self.delete("all")
|
||
width, height = self._width(), max(self.winfo_height(), 1)
|
||
y = height // 2
|
||
self.create_rectangle(2, y - 3, width - 2, y + 3, fill=self.TRACK_COLOR, outline="")
|
||
x = self._x_of_frac(self._frac)
|
||
if x > 4:
|
||
self.create_rectangle(2, y - 3, x, y + 3, fill=self.FILL_COLOR, outline="")
|
||
radius = 6
|
||
self.create_oval(x - radius, y - radius, x + radius, y + radius, fill=self.FILL_COLOR, outline="")
|
||
|
||
def _on_press(self, event):
|
||
self._dragging = True
|
||
self._frac = self._frac_of_event(event)
|
||
self._redraw()
|
||
if self._command:
|
||
self._command(self._frac, "start")
|
||
|
||
def _on_drag(self, event):
|
||
if not self._dragging:
|
||
return
|
||
self._frac = self._frac_of_event(event)
|
||
self._redraw()
|
||
if self._command:
|
||
self._command(self._frac, "drag")
|
||
|
||
def _on_release(self, event):
|
||
if not self._dragging:
|
||
return
|
||
self._dragging = False
|
||
self._frac = self._frac_of_event(event)
|
||
self._redraw()
|
||
if self._command:
|
||
self._command(self._frac, "end")
|
||
|
||
|
||
class MediaPlayerApp(tk.Tk):
|
||
"""播放器主窗口。"""
|
||
|
||
def __init__(self):
|
||
super().__init__()
|
||
self.title("媒体播放器")
|
||
self.geometry("960x600")
|
||
self.minsize(720, 460)
|
||
|
||
self.playlist = []
|
||
self.current_index = -1
|
||
self.volume = 80
|
||
self.rate = 1.0
|
||
settings = self._load_settings()
|
||
self.seek_step = settings["seek_step"]
|
||
self.sentence_mode = tk.BooleanVar(value=settings["sentence_mode"])
|
||
self.loop_mode = "list"
|
||
self._seeking = False
|
||
self._switching_until = 0.0
|
||
self._fullscreen = False
|
||
self._muted_volume = None
|
||
self._photo = None
|
||
self._boundaries = {}
|
||
self._analysis_running = set()
|
||
self._analysis_done = queue.Queue()
|
||
|
||
self.engine = self._create_engine()
|
||
if self.engine is None:
|
||
messagebox.showerror(
|
||
"无法启动",
|
||
"未找到可用的播放内核。\n"
|
||
"请先安装 python-vlc(并安装 VLC 播放器),或安装 pygame。",
|
||
)
|
||
self.destroy()
|
||
return
|
||
|
||
self._build_ui()
|
||
self._bind_shortcuts()
|
||
if isinstance(self.engine, PygameEngine):
|
||
self.status_var.set("pygame 音频模式:未检测到 VLC,视频功能不可用")
|
||
elif isinstance(self.engine, AvEngine):
|
||
self.status_var.set("纯 Python 解码内核:无需安装 VLC,可直接播放音视频")
|
||
self._load_playlist()
|
||
self.protocol("WM_DELETE_WINDOW", self._on_close)
|
||
self.after(100, self._maybe_embed)
|
||
self.after(200, self._tick)
|
||
|
||
def _create_engine(self):
|
||
if vlc is not None:
|
||
try:
|
||
return VlcEngine()
|
||
except Exception as exc:
|
||
print("VLC 内核初始化失败,回退到纯 Python 内核:", exc)
|
||
try:
|
||
return AvEngine()
|
||
except Exception as exc:
|
||
print("纯 Python 内核初始化失败,回退到 pygame:", exc)
|
||
if pygame is not None:
|
||
try:
|
||
return PygameEngine()
|
||
except Exception as exc:
|
||
print("pygame 内核初始化失败:", exc)
|
||
return None
|
||
|
||
def _build_ui(self):
|
||
style = ttk.Style(self)
|
||
if "clam" in style.theme_names():
|
||
style.theme_use("clam")
|
||
|
||
toolbar = ttk.Frame(self, padding=(8, 8))
|
||
toolbar.pack(fill="x")
|
||
|
||
ttk.Button(toolbar, text="打开文件…", command=self.open_files, takefocus=0).pack(side="left", padx=(0, 4))
|
||
ttk.Button(toolbar, text="打开文件夹…", command=self.open_folder, takefocus=0).pack(side="left", padx=(0, 8))
|
||
|
||
self.btn_prev = ttk.Button(toolbar, text="上一首", command=self.prev_track, takefocus=0)
|
||
self.btn_prev.pack(side="left", padx=2)
|
||
self.btn_play = ttk.Button(toolbar, text="播放", command=self.toggle_play, width=8, takefocus=0)
|
||
self.btn_play.pack(side="left", padx=2)
|
||
self.btn_stop = ttk.Button(toolbar, text="停止", command=self.stop_playback, takefocus=0)
|
||
self.btn_stop.pack(side="left", padx=2)
|
||
self.btn_next = ttk.Button(toolbar, text="下一首", command=self.next_track, takefocus=0)
|
||
self.btn_next.pack(side="left", padx=(2, 10))
|
||
|
||
self.btn_loop = ttk.Button(toolbar, text=LOOP_LABELS[self.loop_mode], command=self.cycle_loop, width=9, takefocus=0)
|
||
self.btn_loop.pack(side="left", padx=2)
|
||
self.btn_speed = ttk.Button(toolbar, text="速度 1x", command=self.cycle_speed, width=9, takefocus=0)
|
||
self.btn_speed.pack(side="left", padx=2)
|
||
|
||
ttk.Label(toolbar, text="音量").pack(side="right", padx=(10, 4))
|
||
self.volume_slider = ttk.Scale(
|
||
toolbar,
|
||
from_=0,
|
||
to=100,
|
||
length=110,
|
||
value=self.volume,
|
||
command=self._on_volume_slider,
|
||
takefocus=0,
|
||
)
|
||
self.volume_slider.pack(side="right", padx=(0, 4))
|
||
|
||
main = ttk.Frame(self, padding=(8, 0))
|
||
main.pack(fill="both", expand=True)
|
||
main.columnconfigure(0, weight=1)
|
||
main.rowconfigure(0, weight=1)
|
||
|
||
self.video_frame = tk.Frame(main, bg="#101010")
|
||
self.video_frame.grid(row=0, column=0, sticky="nsew", padx=(0, 8))
|
||
if isinstance(self.engine, AvEngine):
|
||
self.video_canvas = tk.Canvas(self.video_frame, bg="#101010", highlightthickness=0)
|
||
self.video_canvas.pack(fill="both", expand=True)
|
||
hint_text = "打开文件开始播放\n(视频将显示在此窗口)"
|
||
if not self.engine.EMBEDDABLE:
|
||
hint_text = "打开文件开始播放\n(视频将在独立窗口中显示)"
|
||
self.video_hint = tk.Label(self.video_frame, text=hint_text, bg="#101010", fg="#8a8a8a")
|
||
self.video_hint.place(relx=0.5, rely=0.5, anchor="center")
|
||
|
||
side = ttk.Frame(main, width=260)
|
||
side.grid(row=0, column=1, sticky="ns")
|
||
side.columnconfigure(0, weight=1)
|
||
side.rowconfigure(0, weight=1)
|
||
self.listbox = tk.Listbox(
|
||
side,
|
||
activestyle="none",
|
||
selectmode="extended",
|
||
selectbackground="#3f8efc",
|
||
selectforeground="#ffffff",
|
||
exportselection=False,
|
||
highlightthickness=0,
|
||
relief="flat",
|
||
)
|
||
self.listbox.grid(row=0, column=0, sticky="nsew")
|
||
scrollbar = ttk.Scrollbar(side, orient="vertical", command=self.listbox.yview)
|
||
scrollbar.grid(row=0, column=1, sticky="ns")
|
||
self.listbox.configure(yscrollcommand=scrollbar.set)
|
||
self._playlist_menu = tk.Menu(self, tearoff=0)
|
||
self._playlist_menu.add_command(label="播放选中曲目", command=self.play_selected)
|
||
self._playlist_menu.add_separator()
|
||
self._playlist_menu.add_command(label="删除选中曲目", command=self.remove_selected)
|
||
self._playlist_menu.add_command(label="清空播放列表", command=self.clear_playlist)
|
||
if IS_MAC:
|
||
self.listbox.bind("<Button-2>", self._show_playlist_menu)
|
||
self.listbox.bind("<Button-3>", self._show_playlist_menu)
|
||
self.listbox.bind("<Control-Button-1>", self._show_playlist_menu)
|
||
else:
|
||
self.listbox.bind("<Button-3>", self._show_playlist_menu)
|
||
|
||
bottom = ttk.Frame(self, padding=(8, 6))
|
||
bottom.pack(fill="x")
|
||
self.seekbar = SeekBar(bottom, command=self._on_seek_command)
|
||
self.seekbar.pack(fill="x")
|
||
|
||
controls = ttk.Frame(bottom)
|
||
controls.pack(fill="x", pady=(4, 0))
|
||
self.time_var = tk.StringVar(value="00:00 / --:--")
|
||
self.status_var = tk.StringVar(value="就绪 — 打开文件或按 Ctrl+O")
|
||
self.seek_step_var = tk.StringVar(value=str(self.seek_step))
|
||
ttk.Label(controls, textvariable=self.time_var).pack(side="left", anchor="w")
|
||
ttk.Label(controls, text="快进步长:").pack(side="left", padx=(16, 4))
|
||
self.seek_step_spin = ttk.Spinbox(
|
||
controls,
|
||
from_=1,
|
||
to=600,
|
||
increment=1,
|
||
width=5,
|
||
textvariable=self.seek_step_var,
|
||
command=self._commit_seek_step,
|
||
)
|
||
self.seek_step_spin.pack(side="left")
|
||
ttk.Label(controls, text="秒").pack(side="left", padx=(4, 0))
|
||
self.sentence_mode_btn = ttk.Checkbutton(
|
||
controls,
|
||
text="按句跳转",
|
||
variable=self.sentence_mode,
|
||
command=self._on_sentence_mode,
|
||
)
|
||
self.sentence_mode_btn.pack(side="left", padx=(16, 0))
|
||
self.seek_step_spin.bind("<Return>", self._on_seek_step_return)
|
||
self.seek_step_spin.bind("<FocusOut>", lambda _e: self._commit_seek_step())
|
||
ttk.Label(controls, textvariable=self.status_var, anchor="e").pack(side="right", fill="x", expand=True)
|
||
|
||
def _bind_shortcuts(self):
|
||
shortcuts = [
|
||
("<space>", self._on_space),
|
||
("<Left>", self._on_left),
|
||
("<Right>", self._on_right),
|
||
("<Shift-Left>", lambda _e: self.seek_relative(-SEEK_STEP_BIG)),
|
||
("<Shift-Right>", lambda _e: self.seek_relative(SEEK_STEP_BIG)),
|
||
("<Control-Left>", lambda _e: self.prev_track()),
|
||
("<Control-Right>", lambda _e: self.next_track()),
|
||
("<Command-Left>", lambda _e: self.prev_track()),
|
||
("<Command-Right>", lambda _e: self.next_track()),
|
||
("<Up>", self._on_up),
|
||
("<Down>", self._on_down),
|
||
("<Control-Up>", lambda _e: self.adjust_seek_step(1)),
|
||
("<Control-Down>", lambda _e: self.adjust_seek_step(-1)),
|
||
("<Command-Up>", lambda _e: self.adjust_seek_step(1)),
|
||
("<Command-Down>", lambda _e: self.adjust_seek_step(-1)),
|
||
("<m>", lambda _e: self.toggle_mute()),
|
||
("<M>", lambda _e: self.toggle_mute()),
|
||
("<Control-o>", lambda _e: self.open_files()),
|
||
("<Command-o>", lambda _e: self.open_files()),
|
||
("<Control-Shift-O>", lambda _e: self.open_folder()),
|
||
("<Command-Shift-O>", lambda _e: self.open_folder()),
|
||
("<Control-s>", lambda _e: self.stop_playback()),
|
||
("<Command-s>", lambda _e: self.stop_playback()),
|
||
("<s>", lambda _e: self.stop_playback()),
|
||
("<S>", lambda _e: self.stop_playback()),
|
||
("<n>", lambda _e: self.next_track()),
|
||
("<N>", lambda _e: self.next_track()),
|
||
("<p>", lambda _e: self.prev_track()),
|
||
("<P>", lambda _e: self.prev_track()),
|
||
("<l>", lambda _e: self.cycle_loop()),
|
||
("<L>", lambda _e: self.cycle_loop()),
|
||
("<bracketleft>", lambda _e: self.step_speed(-1)),
|
||
("<bracketright>", lambda _e: self.step_speed(1)),
|
||
("<f>", lambda _e: self.toggle_fullscreen()),
|
||
("<F>", lambda _e: self.toggle_fullscreen()),
|
||
("<Escape>", lambda _e: self.leave_fullscreen()),
|
||
("<Delete>", lambda _e: self.remove_selected()),
|
||
("<Return>", lambda _e: self.play_selected()),
|
||
]
|
||
for sequence, handler in shortcuts:
|
||
wrapped = self._break_handler(handler)
|
||
self.bind(sequence, wrapped)
|
||
self.listbox.bind(sequence, wrapped)
|
||
self.listbox.bind("<Double-Button-1>", lambda _e: self.play_selected())
|
||
|
||
@staticmethod
|
||
def _break_handler(handler):
|
||
"""包装快捷键回调:执行后返回 "break",阻止事件继续冒泡造成重复触发。"""
|
||
def run(event):
|
||
handler(event)
|
||
return "break"
|
||
return run
|
||
|
||
def _on_space(self, _event):
|
||
self.toggle_play()
|
||
return "break"
|
||
|
||
def _on_left(self, _event):
|
||
if self.sentence_mode.get() and self._seek_sentence(-1):
|
||
return "break"
|
||
self.seek_relative(-self.seek_step)
|
||
return "break"
|
||
|
||
def _on_right(self, _event):
|
||
if self.sentence_mode.get() and self._seek_sentence(1):
|
||
return "break"
|
||
self.seek_relative(self.seek_step)
|
||
return "break"
|
||
|
||
def _on_up(self, _event):
|
||
self.adjust_volume(5)
|
||
return "break"
|
||
|
||
def _on_down(self, _event):
|
||
self.adjust_volume(-5)
|
||
return "break"
|
||
|
||
def _maybe_embed(self):
|
||
if self.engine.EMBEDDABLE:
|
||
try:
|
||
self.engine.embed(self.video_frame.winfo_id())
|
||
except Exception:
|
||
pass
|
||
|
||
def _tick(self):
|
||
if self.engine is not None:
|
||
while True:
|
||
try:
|
||
done_path = self._analysis_done.get_nowait()
|
||
except queue.Empty:
|
||
break
|
||
if done_path == self._current_path() and self.sentence_mode.get():
|
||
boundaries = self._boundaries.get(done_path)
|
||
if boundaries:
|
||
self.status_var.set(
|
||
f"句边界分析完成:识别到 {len(boundaries)} 句,←/→ 将按句跳转"
|
||
)
|
||
else:
|
||
self.status_var.set("该文件无法识别句子边界,按句跳转不可用")
|
||
for event in self.engine.poll_events():
|
||
if event == "end":
|
||
self._on_media_end()
|
||
elif event == "error":
|
||
self.status_var.set("无法播放该文件,请检查文件格式或 VLC 是否已安装")
|
||
img = self.engine.get_frame()
|
||
if img is not None:
|
||
self._show_frame(img)
|
||
pos = self.engine.get_time()
|
||
length = self.engine.get_length()
|
||
if pos >= 0 and not self._seeking:
|
||
self.time_var.set(f"{fmt_time(pos)} / {fmt_time(length)}")
|
||
if length > 0:
|
||
self.seekbar.set_frac(pos / length)
|
||
playing = self.engine.is_playing()
|
||
self.btn_play.config(text="暂停" if playing else "播放")
|
||
self.after(33, self._tick)
|
||
|
||
def _show_frame(self, img):
|
||
if ImageTk is None:
|
||
return
|
||
canvas = getattr(self, "video_canvas", None)
|
||
if canvas is None:
|
||
return
|
||
width = canvas.winfo_width()
|
||
height = canvas.winfo_height()
|
||
if width < 2 or height < 2:
|
||
return
|
||
iw, ih = img.size
|
||
if iw < 1 or ih < 1:
|
||
return
|
||
scale = min(width / iw, height / ih)
|
||
size = (max(1, int(iw * scale)), max(1, int(ih * scale)))
|
||
if size != img.size:
|
||
img = img.resize(size, PILImage.Resampling.BILINEAR)
|
||
self._photo = ImageTk.PhotoImage(img)
|
||
canvas.delete("all")
|
||
canvas.create_image(width // 2, height // 2, image=self._photo, anchor="center")
|
||
|
||
def open_files(self):
|
||
filetypes = [
|
||
("媒体文件", " ".join("*" + ext for ext in sorted(SUPPORTED_EXTS))),
|
||
("所有文件", "*"),
|
||
]
|
||
paths = filedialog.askopenfilenames(title="选择媒体文件", filetypes=filetypes)
|
||
if paths:
|
||
self.add_paths(list(paths))
|
||
|
||
def open_folder(self):
|
||
folder = filedialog.askdirectory(title="选择包含媒体文件的文件夹")
|
||
if folder:
|
||
self.add_paths([folder])
|
||
|
||
def add_paths(self, paths):
|
||
start = len(self.playlist)
|
||
added = []
|
||
for item in paths:
|
||
path = Path(item)
|
||
if path.is_dir():
|
||
for child in sorted(path.rglob("*")):
|
||
if child.is_file() and child.suffix.lower() in SUPPORTED_EXTS:
|
||
added.append(str(child))
|
||
elif path.is_file() and path.suffix.lower() in SUPPORTED_EXTS:
|
||
added.append(str(path))
|
||
if not added:
|
||
self.status_var.set("所选路径中没有支持的媒体文件")
|
||
return
|
||
self.playlist.extend(added)
|
||
for name in added:
|
||
self.listbox.insert(tk.END, Path(name).name)
|
||
if not self.engine.is_active():
|
||
self.play_index(start)
|
||
else:
|
||
self._update_status()
|
||
self._save_playlist()
|
||
|
||
def _playlist_path(self):
|
||
return Path.home() / ".mediaplayer_playlist.json"
|
||
|
||
def _settings_path(self):
|
||
return Path.home() / ".mediaplayer_settings.json"
|
||
|
||
def _load_settings(self):
|
||
try:
|
||
data = json.loads(self._settings_path().read_text(encoding="utf-8"))
|
||
except Exception:
|
||
data = {}
|
||
try:
|
||
step = int(data.get("seek_step", SEEK_STEP))
|
||
except (TypeError, ValueError):
|
||
step = SEEK_STEP
|
||
if not 1 <= step <= 600:
|
||
step = SEEK_STEP
|
||
return {"seek_step": step, "sentence_mode": bool(data.get("sentence_mode", False))}
|
||
|
||
def _save_settings(self):
|
||
try:
|
||
self._settings_path().write_text(
|
||
json.dumps(
|
||
{
|
||
"seek_step": self.seek_step,
|
||
"sentence_mode": bool(self.sentence_mode.get()),
|
||
},
|
||
ensure_ascii=False,
|
||
indent=2,
|
||
),
|
||
encoding="utf-8",
|
||
)
|
||
except Exception as exc:
|
||
print("保存设置失败:", exc)
|
||
|
||
def _save_playlist(self):
|
||
try:
|
||
data = {"playlist": list(self.playlist), "current_index": self.current_index}
|
||
self._playlist_path().write_text(
|
||
json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8"
|
||
)
|
||
except Exception as exc:
|
||
print("保存播放列表失败:", exc)
|
||
|
||
def _load_playlist(self):
|
||
path = self._playlist_path()
|
||
if not path.exists():
|
||
return
|
||
try:
|
||
data = json.loads(path.read_text(encoding="utf-8"))
|
||
items = data.get("playlist", [])
|
||
except Exception as exc:
|
||
print("读取播放列表失败:", exc)
|
||
return
|
||
added = []
|
||
for item in items:
|
||
p = Path(item)
|
||
if p.is_file() and p.suffix.lower() in SUPPORTED_EXTS:
|
||
added.append(str(p))
|
||
if not added:
|
||
return
|
||
self.playlist.extend(added)
|
||
for name in added:
|
||
self.listbox.insert(tk.END, Path(name).name)
|
||
saved_index = data.get("current_index", -1)
|
||
if 0 <= saved_index < len(self.playlist):
|
||
self.current_index = saved_index
|
||
self.listbox.selection_set(saved_index)
|
||
self.listbox.see(saved_index)
|
||
msg = f"已恢复上次的播放列表({len(added)} 个文件)"
|
||
dropped = len(items) - len(added)
|
||
if dropped:
|
||
msg += f",已忽略 {dropped} 个不存在的文件"
|
||
self.status_var.set(msg)
|
||
|
||
def play_index(self, index):
|
||
if not (0 <= index < len(self.playlist)):
|
||
return
|
||
self.current_index = index
|
||
path = self.playlist[index]
|
||
self._switching_until = time.monotonic() + 0.8
|
||
if self.engine.EMBEDDABLE:
|
||
try:
|
||
self.engine.embed(self.video_frame.winfo_id())
|
||
except Exception:
|
||
pass
|
||
try:
|
||
ok = self.engine.play(path)
|
||
except Exception:
|
||
ok = False
|
||
if not ok:
|
||
self.status_var.set(f"无法播放:{Path(path).name}")
|
||
return
|
||
self.listbox.selection_clear(0, tk.END)
|
||
self.listbox.selection_set(index)
|
||
self.listbox.see(index)
|
||
if Path(path).suffix.lower() in VIDEO_EXTS and self.engine.EMBEDDABLE:
|
||
self.video_hint.place_forget()
|
||
else:
|
||
self.video_hint.place(relx=0.5, rely=0.5, anchor="center")
|
||
self.title(f"{Path(path).name} — 媒体播放器")
|
||
self._update_status()
|
||
self._save_playlist()
|
||
if self.sentence_mode.get():
|
||
self._ensure_sentence_boundaries(path)
|
||
|
||
def toggle_play(self):
|
||
if not self.playlist:
|
||
self.open_files()
|
||
return
|
||
if not self.engine.is_active():
|
||
self.play_index(self.current_index if self.current_index >= 0 else 0)
|
||
return
|
||
self.engine.toggle_pause()
|
||
self._update_status()
|
||
|
||
def stop_playback(self):
|
||
self.engine.stop()
|
||
self._seeking = False
|
||
self.time_var.set("00:00 / --:--")
|
||
self.seekbar.set_frac(0.0)
|
||
self.btn_play.config(text="播放")
|
||
canvas = getattr(self, "video_canvas", None)
|
||
if canvas is not None:
|
||
canvas.delete("all")
|
||
self._photo = None
|
||
self.video_hint.place(relx=0.5, rely=0.5, anchor="center")
|
||
self._update_status()
|
||
|
||
def next_track(self):
|
||
if not self.playlist:
|
||
self.open_files()
|
||
return
|
||
self.play_index((self.current_index + 1) % len(self.playlist))
|
||
|
||
def prev_track(self):
|
||
if not self.playlist:
|
||
self.open_files()
|
||
return
|
||
self.play_index((self.current_index - 1) % len(self.playlist))
|
||
|
||
def seek_relative(self, seconds):
|
||
pos = self.engine.get_time()
|
||
if pos < 0:
|
||
return
|
||
self.seek_to(pos + seconds * 1000)
|
||
|
||
def _commit_seek_step(self):
|
||
raw = self.seek_step_var.get().strip()
|
||
try:
|
||
value = int(float(raw))
|
||
except ValueError:
|
||
value = 0
|
||
if not 1 <= value <= 600:
|
||
self.seek_step_var.set(str(self.seek_step))
|
||
self.status_var.set("快进步长需为 1-600 之间的整数")
|
||
return
|
||
if value != self.seek_step:
|
||
self.seek_step = value
|
||
self._save_settings()
|
||
self.status_var.set(f"快进步长已设为 {value} 秒(已保存)")
|
||
|
||
def adjust_seek_step(self, delta):
|
||
value = max(1, min(600, self.seek_step + delta))
|
||
if value == self.seek_step:
|
||
self.status_var.set(f"快进步长已到{'上限' if delta > 0 else '下限'}({value} 秒)")
|
||
return
|
||
self.seek_step = value
|
||
self.seek_step_var.set(str(value))
|
||
self._save_settings()
|
||
self.status_var.set(f"快进步长已设为 {value} 秒(已保存)")
|
||
|
||
def _on_seek_step_return(self, _event):
|
||
self._commit_seek_step()
|
||
self.focus_set()
|
||
return "break"
|
||
|
||
def _current_path(self):
|
||
if 0 <= self.current_index < len(self.playlist):
|
||
return self.playlist[self.current_index]
|
||
return None
|
||
|
||
def _on_sentence_mode(self):
|
||
self._save_settings()
|
||
if not self.sentence_mode.get():
|
||
self.status_var.set("按句跳转已关闭")
|
||
return
|
||
path = self._current_path()
|
||
if path is None:
|
||
self.status_var.set("按句跳转已开启(请先添加并播放文件)")
|
||
return
|
||
if path not in self._boundaries:
|
||
self._ensure_sentence_boundaries(path)
|
||
self.status_var.set("正在分析句边界…")
|
||
elif self._boundaries[path]:
|
||
self.status_var.set(f"按句跳转已开启(识别到 {len(self._boundaries[path])} 句)")
|
||
else:
|
||
self.status_var.set("该文件无法识别句子边界,按句跳转不可用")
|
||
|
||
def _ensure_sentence_boundaries(self, path):
|
||
if path in self._boundaries or path in self._analysis_running:
|
||
return
|
||
self._analysis_running.add(path)
|
||
threading.Thread(target=self._analyze_sentences, args=(path,), daemon=True).start()
|
||
|
||
def _analyze_sentences(self, path):
|
||
boundaries = detect_sentence_boundaries(path)
|
||
self._boundaries[path] = boundaries
|
||
self._analysis_running.discard(path)
|
||
self._analysis_done.put(path)
|
||
|
||
def _seek_sentence(self, direction):
|
||
path = self._current_path()
|
||
if path is None:
|
||
return False
|
||
boundaries = self._boundaries.get(path)
|
||
if not boundaries:
|
||
return False
|
||
pos = self.engine.get_time()
|
||
if pos < 0:
|
||
return False
|
||
pos_sec = pos / 1000.0
|
||
if direction > 0:
|
||
for boundary in boundaries:
|
||
if boundary > pos_sec + 0.15:
|
||
self.seek_to(int(boundary * 1000))
|
||
return True
|
||
return False
|
||
target = 0.0
|
||
for boundary in reversed(boundaries):
|
||
if boundary < pos_sec - 0.25:
|
||
target = boundary
|
||
break
|
||
if target > 0.0 or pos_sec > 0.25:
|
||
self.seek_to(int(target * 1000))
|
||
return True
|
||
return False
|
||
|
||
def seek_to(self, ms):
|
||
ms = max(0, int(ms))
|
||
if not self.engine.seek(ms):
|
||
self.status_var.set("当前文件格式不支持定位")
|
||
|
||
def _on_seek_command(self, frac, phase):
|
||
length = self.engine.get_length()
|
||
if length <= 0:
|
||
return
|
||
target = int(frac * length)
|
||
if phase in ("start", "drag"):
|
||
self._seeking = True
|
||
self.time_var.set(f"{fmt_time(target)} / {fmt_time(length)}")
|
||
else:
|
||
self._seeking = False
|
||
self.seek_to(target)
|
||
|
||
def adjust_volume(self, delta):
|
||
self.volume = max(0, min(100, self.volume + delta))
|
||
self.volume_slider.set(self.volume)
|
||
self.engine.set_volume(self.volume)
|
||
if self.volume > 0:
|
||
self._muted_volume = None
|
||
self._update_status()
|
||
|
||
def _on_volume_slider(self, value):
|
||
self.volume = max(0, min(100, int(float(value))))
|
||
self.engine.set_volume(self.volume)
|
||
if self.volume > 0:
|
||
self._muted_volume = None
|
||
self._update_status()
|
||
|
||
def toggle_mute(self):
|
||
if self.volume > 0:
|
||
self._muted_volume = self.volume
|
||
self.adjust_volume(-self.volume)
|
||
else:
|
||
self.adjust_volume(self._muted_volume or 80)
|
||
|
||
def cycle_loop(self):
|
||
order = ["off", "one", "list"]
|
||
self.loop_mode = order[(order.index(self.loop_mode) + 1) % len(order)]
|
||
self.btn_loop.config(text=LOOP_LABELS[self.loop_mode])
|
||
self._update_status()
|
||
|
||
def cycle_speed(self):
|
||
self.step_speed(1)
|
||
|
||
def step_speed(self, direction):
|
||
if not getattr(self.engine, "supports_rate", True):
|
||
self.status_var.set("当前播放内核不支持变速")
|
||
return
|
||
index = SPEED_PRESETS.index(self.rate) if self.rate in SPEED_PRESETS else SPEED_PRESETS.index(1.0)
|
||
index = (index + direction) % len(SPEED_PRESETS)
|
||
self.rate = SPEED_PRESETS[index]
|
||
self.engine.set_rate(self.rate)
|
||
self.btn_speed.config(text=f"速度 {self.rate:g}x")
|
||
self._update_status()
|
||
|
||
def toggle_fullscreen(self):
|
||
self._fullscreen = not self._fullscreen
|
||
if isinstance(self.engine, AvEngine):
|
||
self.attributes("-fullscreen", self._fullscreen)
|
||
else:
|
||
self.engine.set_fullscreen(self._fullscreen)
|
||
|
||
def leave_fullscreen(self):
|
||
self._fullscreen = False
|
||
if isinstance(self.engine, AvEngine):
|
||
self.attributes("-fullscreen", False)
|
||
else:
|
||
self.engine.set_fullscreen(False)
|
||
|
||
def play_selected(self):
|
||
selection = self.listbox.curselection()
|
||
if selection:
|
||
self.play_index(selection[0])
|
||
|
||
def remove_selected(self):
|
||
selection = list(self.listbox.curselection())
|
||
if not selection:
|
||
return
|
||
if self.current_index in selection:
|
||
self.engine.stop()
|
||
self.current_index = -1
|
||
for index in reversed(selection):
|
||
self.playlist.pop(index)
|
||
self.listbox.delete(index)
|
||
self._update_status()
|
||
self._save_playlist()
|
||
|
||
def _show_playlist_menu(self, event):
|
||
has_selection = bool(self.listbox.curselection())
|
||
self._playlist_menu.entryconfig("播放选中曲目", state="normal" if has_selection else "disabled")
|
||
self._playlist_menu.entryconfig("删除选中曲目", state="normal" if has_selection else "disabled")
|
||
try:
|
||
self._playlist_menu.tk_popup(event.x_root, event.y_root)
|
||
finally:
|
||
self._playlist_menu.grab_release()
|
||
|
||
def clear_playlist(self):
|
||
if not self.playlist:
|
||
return
|
||
if not messagebox.askyesno("清空播放列表", "确定要清空整个播放列表吗?"):
|
||
return
|
||
self.stop_playback()
|
||
self.playlist.clear()
|
||
self.listbox.delete(0, tk.END)
|
||
self.current_index = -1
|
||
self._save_playlist()
|
||
self.status_var.set("播放列表已清空")
|
||
|
||
def _update_status(self):
|
||
parts = [f"循环:{LOOP_LABELS[self.loop_mode].split(':')[1].strip()}"]
|
||
parts.append(f"速度 {self.rate:g}x")
|
||
parts.append(f"音量 {self.volume}")
|
||
if isinstance(self.engine, PygameEngine):
|
||
parts.append("pygame 音频模式(未检测到 VLC,视频不可用)")
|
||
elif isinstance(self.engine, AvEngine):
|
||
parts.append("纯 Python 解码内核(无需 VLC)")
|
||
if 0 <= self.current_index < len(self.playlist):
|
||
name = Path(self.playlist[self.current_index]).name
|
||
total = len(self.playlist)
|
||
parts.insert(0, f"正在播放:{name}(第 {self.current_index + 1}/{total} 首)")
|
||
self.status_var.set("|".join(parts))
|
||
|
||
def _on_media_end(self):
|
||
if time.monotonic() < self._switching_until:
|
||
return
|
||
if not self.playlist or not self.engine.is_active():
|
||
return
|
||
if self.loop_mode == "one":
|
||
self.play_index(self.current_index)
|
||
return
|
||
next_index = self.current_index + 1
|
||
if next_index < len(self.playlist):
|
||
self.play_index(next_index)
|
||
elif self.loop_mode == "list":
|
||
self.play_index(0)
|
||
else:
|
||
self.stop_playback()
|
||
self.status_var.set("播放结束")
|
||
|
||
def _on_close(self):
|
||
self._save_playlist()
|
||
self.engine.release()
|
||
self.destroy()
|
||
|
||
|
||
def main():
|
||
app = MediaPlayerApp()
|
||
if app.engine is None:
|
||
return
|
||
if sys.argv[1:]:
|
||
app.add_paths(sys.argv[1:])
|
||
app.mainloop()
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|