feat: i18n support, tri-color icon, fix crash
- Add i18n: Localization.swift + zh-Hans/en Localizable.strings - Add MiniPlayerIcon SwiftUI view (tri-color play button + ring) - Fix crash: isInteracting/lastInteraction no longer @Published - Fix crash: ExitFullscreen notification wrapped in DispatchQueue.main.async - Auto-hide toolbar uses local @State + Timer (not @Published) - Replace emoji logo with MiniPlayerIcon - Move icon sets out of Resources/ to avoid SPM conflicts - Package.swift: add defaultLocalization, process Resources
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 1.8 KiB After Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 503 B After Width: | Height: | Size: 503 B |
|
Before Width: | Height: | Size: 683 B After Width: | Height: | Size: 683 B |
|
Before Width: | Height: | Size: 696 B After Width: | Height: | Size: 696 B |
|
Before Width: | Height: | Size: 903 B After Width: | Height: | Size: 903 B |
|
Before Width: | Height: | Size: 1.0 KiB After Width: | Height: | Size: 1.0 KiB |
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 3.4 KiB After Width: | Height: | Size: 3.4 KiB |
|
Before Width: | Height: | Size: 271 B After Width: | Height: | Size: 271 B |
|
Before Width: | Height: | Size: 445 B After Width: | Height: | Size: 445 B |
|
Before Width: | Height: | Size: 3.4 KiB After Width: | Height: | Size: 3.4 KiB |
|
Before Width: | Height: | Size: 6.3 KiB After Width: | Height: | Size: 6.3 KiB |
|
Before Width: | Height: | Size: 445 B After Width: | Height: | Size: 445 B |
|
Before Width: | Height: | Size: 780 B After Width: | Height: | Size: 780 B |
|
Before Width: | Height: | Size: 6.3 KiB After Width: | Height: | Size: 6.3 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 16 KiB |
@ -3,6 +3,7 @@ import PackageDescription
|
||||
|
||||
let package = Package(
|
||||
name: "MiniPlayer",
|
||||
defaultLocalization: "en",
|
||||
platforms: [
|
||||
.iOS(.v17),
|
||||
.macOS(.v14)
|
||||
@ -14,7 +15,8 @@ let package = Package(
|
||||
targets: [
|
||||
.executableTarget(
|
||||
name: "MiniPlayer",
|
||||
dependencies: []
|
||||
dependencies: [],
|
||||
resources: [.process("Resources")]
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import SwiftUI
|
||||
import AVFoundation
|
||||
import Combine
|
||||
|
||||
@main
|
||||
struct MiniPlayerApp: App {
|
||||
@ -14,31 +15,31 @@ struct MiniPlayerApp: App {
|
||||
#if os(macOS)
|
||||
.commands {
|
||||
CommandGroup(replacing: .newItem) {}
|
||||
CommandMenu("播放") {
|
||||
Button("播放/暂停") { bridge.togglePlayPause() }
|
||||
CommandMenu(L.playback) {
|
||||
Button(L.playPause) { bridge.togglePlayPause() }
|
||||
.keyboardShortcut(" ", modifiers: [])
|
||||
Divider()
|
||||
Button("上一首") { bridge.playPrev() }
|
||||
Button(L.prev) { bridge.playPrev() }
|
||||
.keyboardShortcut("[", modifiers: [])
|
||||
Button("下一首") { bridge.playNext() }
|
||||
Button(L.next) { bridge.playNext() }
|
||||
.keyboardShortcut("]", modifiers: [])
|
||||
Divider()
|
||||
Button("全屏") { bridge.toggleFullscreen() }
|
||||
Button(L.fullscreen) { bridge.toggleFullscreen() }
|
||||
.keyboardShortcut("f", modifiers: .command)
|
||||
Divider()
|
||||
Button("音量+") { bridge.adjustVolume(by: 0.1) }
|
||||
Button(L.volUp) { bridge.adjustVolume(by: 0.1) }
|
||||
.keyboardShortcut("=", modifiers: .command)
|
||||
Button("音量-") { bridge.adjustVolume(by: -0.1) }
|
||||
Button(L.volDown) { bridge.adjustVolume(by: -0.1) }
|
||||
.keyboardShortcut("-", modifiers: .command)
|
||||
Divider()
|
||||
Button("循环模式") { bridge.cycleRepeatMode() }
|
||||
Button(L.repeatMode) { bridge.cycleRepeatMode() }
|
||||
.keyboardShortcut("r", modifiers: .command)
|
||||
}
|
||||
CommandMenu("文件") {
|
||||
Button("打开文件...") { bridge.openFileDialog() }
|
||||
CommandMenu(L.file) {
|
||||
Button(L.openFile) { bridge.openFileDialog() }
|
||||
.keyboardShortcut("o", modifiers: .command)
|
||||
Divider()
|
||||
Button("添加URL...") { bridge.showURLDialog = true }
|
||||
Button(L.addURL) { bridge.showURLDialog = true }
|
||||
.keyboardShortcut("u", modifiers: .command)
|
||||
}
|
||||
}
|
||||
@ -46,9 +47,15 @@ struct MiniPlayerApp: App {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 主内容视图(直接SwiftUI,不经过BricksView)
|
||||
// MARK: - 主内容视图
|
||||
struct ContentView: View {
|
||||
@ObservedObject var bridge: PlayerBridge
|
||||
// 自动隐藏:本地状态,不经过 bridge 的 @Published
|
||||
@State private var toolbarVisible = false
|
||||
@State private var isHoveringToolbar = false
|
||||
@State private var lastInteraction = Date()
|
||||
private let hideTimer = Timer.publish(every: 2, on: .main, in: .common).autoconnect()
|
||||
private let autoHideInterval: TimeInterval = 60
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
@ -61,7 +68,10 @@ struct ContentView: View {
|
||||
// 单击播放/暂停 + 双击全屏
|
||||
Color.clear
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture { bridge.togglePlayPause() }
|
||||
.onTapGesture {
|
||||
bridge.togglePlayPause()
|
||||
touchInteraction()
|
||||
}
|
||||
#if os(macOS)
|
||||
.onTapGesture(count: 2) { bridge.toggleFullscreen() }
|
||||
#endif
|
||||
@ -70,10 +80,13 @@ struct ContentView: View {
|
||||
// Logo(左上角,始终显示)
|
||||
VStack {
|
||||
HStack {
|
||||
Button(action: { bridge.showToolbar.toggle() }) {
|
||||
Text("🎬")
|
||||
.font(.system(size: 28))
|
||||
.padding(8)
|
||||
Button(action: {
|
||||
toolbarVisible.toggle()
|
||||
if toolbarVisible { touchInteraction() }
|
||||
}) {
|
||||
MiniPlayerIcon()
|
||||
.frame(width: 36, height: 36)
|
||||
.padding(6)
|
||||
.background(.black.opacity(0.4))
|
||||
.cornerRadius(10)
|
||||
}
|
||||
@ -85,11 +98,11 @@ struct ContentView: View {
|
||||
Spacer()
|
||||
}
|
||||
|
||||
// 底部 Toolbar(半透明,点击logo才显示)
|
||||
if bridge.showToolbar {
|
||||
// 底部 Toolbar(半透明)
|
||||
if toolbarVisible {
|
||||
VStack {
|
||||
Spacer()
|
||||
ControlToolbar(bridge: bridge)
|
||||
ControlToolbar(bridge: bridge, isHovering: $isHoveringToolbar, onTouch: touchInteraction)
|
||||
.transition(.move(edge: .bottom).combined(with: .opacity))
|
||||
}
|
||||
}
|
||||
@ -116,19 +129,33 @@ struct ContentView: View {
|
||||
.sheet(isPresented: $bridge.showPlaylistSheet) {
|
||||
PlaylistWindowView(bridge: bridge)
|
||||
}
|
||||
.animation(.easeInOut(duration: 0.25), value: bridge.showToolbar)
|
||||
.animation(.easeInOut(duration: 0.25), value: toolbarVisible)
|
||||
// 自动隐藏 Timer:每2秒检查一次
|
||||
.onReceive(hideTimer) { _ in
|
||||
guard toolbarVisible else { return }
|
||||
if isHoveringToolbar { lastInteraction = Date(); return }
|
||||
if Date().timeIntervalSince(lastInteraction) >= autoHideInterval {
|
||||
toolbarVisible = false
|
||||
}
|
||||
}
|
||||
// 全屏退出通知 — async 避免在 SwiftUI 更新周期内操作 NSWindow
|
||||
.onReceive(NotificationCenter.default.publisher(for: .init("ExitFullscreen"))) { _ in
|
||||
bridge.toggleFullscreen()
|
||||
}
|
||||
.onDisappear {
|
||||
bridge.cleanup()
|
||||
DispatchQueue.main.async { [bridge] in
|
||||
bridge.toggleFullscreen()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func touchInteraction() {
|
||||
lastInteraction = Date()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 底部控制栏
|
||||
struct ControlToolbar: View {
|
||||
@ObservedObject var bridge: PlayerBridge
|
||||
@Binding var isHovering: Bool
|
||||
let onTouch: () -> Void
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 6) {
|
||||
@ -151,16 +178,16 @@ struct ControlToolbar: View {
|
||||
// 按钮行
|
||||
HStack(spacing: 10) {
|
||||
#if os(macOS)
|
||||
ToolbarButton(label: "📂 打开") { bridge.openFileDialog() }
|
||||
ToolbarButton(label: "📂 \(L.open)") { bridge.openFileDialog(); onTouch() }
|
||||
#endif
|
||||
ToolbarButton(label: "⏮") { bridge.playPrev() }
|
||||
ToolbarButton(label: bridge.isPlaying ? "⏸" : "▶️") { bridge.togglePlayPause() }
|
||||
ToolbarButton(label: "⏭") { bridge.playNext() }
|
||||
ToolbarButton(label: "⏮") { bridge.playPrev(); onTouch() }
|
||||
ToolbarButton(label: bridge.isPlaying ? "⏸" : "▶️") { bridge.togglePlayPause(); onTouch() }
|
||||
ToolbarButton(label: "⏭") { bridge.playNext(); onTouch() }
|
||||
|
||||
Spacer()
|
||||
|
||||
// 音量控制
|
||||
ToolbarButton(label: "🔈") { bridge.adjustVolume(by: -0.1) }
|
||||
ToolbarButton(label: "🔈") { bridge.adjustVolume(by: -0.1); onTouch() }
|
||||
|
||||
// 音量条
|
||||
GeometryReader { geo in
|
||||
@ -178,6 +205,7 @@ struct ControlToolbar: View {
|
||||
.onChanged { value in
|
||||
let ratio = max(0, min(1, value.location.x / geo.size.width))
|
||||
bridge.setVolume(Float(ratio))
|
||||
onTouch()
|
||||
}
|
||||
)
|
||||
}
|
||||
@ -188,7 +216,7 @@ struct ControlToolbar: View {
|
||||
.foregroundColor(.white.opacity(0.7))
|
||||
.frame(width: 30)
|
||||
|
||||
ToolbarButton(label: "🔊") { bridge.adjustVolume(by: 0.1) }
|
||||
ToolbarButton(label: "🔊") { bridge.adjustVolume(by: 0.1); onTouch() }
|
||||
|
||||
Spacer()
|
||||
|
||||
@ -196,20 +224,19 @@ struct ControlToolbar: View {
|
||||
.font(.system(size: 11))
|
||||
.foregroundColor(.white.opacity(0.7))
|
||||
|
||||
ToolbarButton(label: "🎵 音轨") { bridge.showTrackDialog = true }
|
||||
ToolbarButton(label: "\(bridge.repeatMode.icon) \(bridge.repeatMode.rawValue)") { bridge.cycleRepeatMode() }
|
||||
ToolbarButton(label: "⛶") { bridge.toggleFullscreen() }
|
||||
ToolbarButton(label: "📋 列表 (\(bridge.queue.count))") { bridge.togglePlaylistWindow() }
|
||||
ToolbarButton(label: L.audioTrack) { bridge.showTrackDialog = true; onTouch() }
|
||||
ToolbarButton(label: "\(bridge.repeatMode.icon) \(bridge.repeatMode.displayName)") { bridge.cycleRepeatMode(); onTouch() }
|
||||
ToolbarButton(label: "⛶") { bridge.toggleFullscreen(); onTouch() }
|
||||
ToolbarButton(label: "📋 \(L.list) (\(bridge.queue.count))") { bridge.togglePlaylistWindow(); onTouch() }
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 8)
|
||||
.background(.black.opacity(0.55))
|
||||
.onHover { hovering in
|
||||
bridge.isInteracting = hovering
|
||||
if hovering { bridge.recordInteraction() }
|
||||
isHovering = hovering
|
||||
if hovering { onTouch() }
|
||||
}
|
||||
.onTapGesture { bridge.recordInteraction() }
|
||||
}
|
||||
}
|
||||
|
||||
@ -241,10 +268,10 @@ struct PlaylistWindowView: View {
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
HStack {
|
||||
Button("📂 添加文件") { bridge.openFileDialog() }
|
||||
Button("🔗 添加URL") { bridge.showURLDialog = true }
|
||||
Button(L.addFile) { bridge.openFileDialog() }
|
||||
Button(L.addURLBtn) { bridge.showURLDialog = true }
|
||||
Spacer()
|
||||
Text("\(bridge.queue.count) 项")
|
||||
Text("\(bridge.queue.count) \(L.itemsCount)")
|
||||
.font(.caption).foregroundColor(.secondary)
|
||||
}
|
||||
.padding(12)
|
||||
@ -253,7 +280,7 @@ struct PlaylistWindowView: View {
|
||||
Divider()
|
||||
|
||||
if bridge.queue.isEmpty {
|
||||
VStack { Spacer(); Text("暂无媒体").foregroundColor(.secondary); Spacer() }
|
||||
VStack { Spacer(); Text(L.noMedia).foregroundColor(.secondary); Spacer() }
|
||||
} else {
|
||||
ScrollView {
|
||||
LazyVStack(spacing: 2) {
|
||||
@ -292,13 +319,13 @@ struct URLInputDialog: View {
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 16) {
|
||||
Text("添加媒体URL").font(.headline)
|
||||
Text(L.addMediaURL).font(.headline)
|
||||
TextField("https://example.com/video.m3u8", text: $url)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
HStack {
|
||||
Button("取消") { dismiss() }
|
||||
Button(L.cancel) { dismiss() }
|
||||
Spacer()
|
||||
Button("添加") {
|
||||
Button(L.add) {
|
||||
if !url.isEmpty { bridge.addURL(url); dismiss() }
|
||||
}
|
||||
.keyboardShortcut(.defaultAction)
|
||||
@ -317,11 +344,11 @@ struct TrackSelectDialog: View {
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 12) {
|
||||
Text("选择音轨").font(.headline)
|
||||
Text(L.selectTrack).font(.headline)
|
||||
ForEach(Array(bridge.availableTracks.enumerated()), id: \.offset) { idx, track in
|
||||
Button(action: { bridge.selectTrack(index: idx); dismiss() }) {
|
||||
HStack {
|
||||
Text("Track \(idx + 1)")
|
||||
Text("\(L.track) \(idx + 1)")
|
||||
Spacer()
|
||||
if idx == bridge.currentTrackIndex {
|
||||
Text("✓").foregroundColor(.green)
|
||||
@ -331,7 +358,7 @@ struct TrackSelectDialog: View {
|
||||
}.buttonStyle(.plain)
|
||||
}
|
||||
Divider()
|
||||
Button("关闭") { dismiss() }
|
||||
Button(L.close) { dismiss() }
|
||||
}
|
||||
.padding(20)
|
||||
.frame(minWidth: 250)
|
||||
|
||||
@ -16,9 +16,17 @@ struct MediaItem: Identifiable, Equatable {
|
||||
|
||||
/// 循环模式
|
||||
enum RepeatMode: String, CaseIterable {
|
||||
case none = "不循环"
|
||||
case single = "单曲循环"
|
||||
case all = "列表循环"
|
||||
case none = "none"
|
||||
case single = "single"
|
||||
case all = "all"
|
||||
|
||||
var displayName: String {
|
||||
switch self {
|
||||
case .none: return L.repeatNone
|
||||
case .single: return L.repeatSingle
|
||||
case .all: return L.repeatAll
|
||||
}
|
||||
}
|
||||
|
||||
var icon: String {
|
||||
switch self {
|
||||
@ -137,7 +145,7 @@ final class PlayerBridge: ObservableObject {
|
||||
}
|
||||
|
||||
loadTrackInfo(playerItem)
|
||||
showToast("正在播放: \(item.name)")
|
||||
showToast(L.nowPlaying(item.name))
|
||||
}
|
||||
|
||||
// MARK: - 播放结束
|
||||
@ -246,13 +254,13 @@ final class PlayerBridge: ObservableObject {
|
||||
}
|
||||
|
||||
private func updateTrackLabel() {
|
||||
currentTrackLabel = "🎵 Track \(currentTrackIndex + 1)/\(max(availableTracks.count, 1))"
|
||||
currentTrackLabel = "🎵 \(L.track) \(currentTrackIndex + 1)/\(max(availableTracks.count, 1))"
|
||||
}
|
||||
|
||||
// MARK: - 循环模式
|
||||
func cycleRepeatMode() {
|
||||
repeatMode = repeatMode.next
|
||||
showToast("循环模式: \(repeatMode.rawValue)")
|
||||
showToast(L.repeatModeLabel(repeatMode.displayName))
|
||||
}
|
||||
|
||||
// MARK: - 全屏(视频内容全屏,非窗口全屏)
|
||||
@ -305,7 +313,7 @@ final class PlayerBridge: ObservableObject {
|
||||
}
|
||||
|
||||
func addURL(_ urlString: String) {
|
||||
guard let url = URL(string: urlString) else { showToast("无效URL"); return }
|
||||
guard let url = URL(string: urlString) else { showToast(L.invalidURL); return }
|
||||
let name = url.lastPathComponent.isEmpty ? (url.host ?? urlString) : url.lastPathComponent
|
||||
let type = urlString.contains(".m3u8") ? "stream" : "url"
|
||||
addItem(url: url, name: name, type: type)
|
||||
@ -351,7 +359,7 @@ final class PlayerBridge: ObservableObject {
|
||||
contentRect: NSRect(x: 0, y: 0, width: 400, height: 500),
|
||||
styleMask: [.titled, .closable, .resizable], backing: .buffered, defer: false
|
||||
)
|
||||
win.title = "播放列表 (\(queue.count))"
|
||||
win.title = "\(L.playlist) (\(queue.count))"
|
||||
win.isReleasedWhenClosed = false
|
||||
win.center()
|
||||
win.contentView = NSHostingView(rootView: PlaylistWindowView(bridge: self))
|
||||
|
||||