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
This commit is contained in:
yumoqing 2026-06-22 00:40:20 +08:00
parent 3e3a990f5e
commit aa19ab9799
25 changed files with 94 additions and 57 deletions

View File

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 16 KiB

View File

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

View File

Before

Width:  |  Height:  |  Size: 2.0 KiB

After

Width:  |  Height:  |  Size: 2.0 KiB

View File

Before

Width:  |  Height:  |  Size: 503 B

After

Width:  |  Height:  |  Size: 503 B

View File

Before

Width:  |  Height:  |  Size: 683 B

After

Width:  |  Height:  |  Size: 683 B

View File

Before

Width:  |  Height:  |  Size: 696 B

After

Width:  |  Height:  |  Size: 696 B

View File

Before

Width:  |  Height:  |  Size: 903 B

After

Width:  |  Height:  |  Size: 903 B

View File

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

Before

Width:  |  Height:  |  Size: 3.4 KiB

After

Width:  |  Height:  |  Size: 3.4 KiB

View File

Before

Width:  |  Height:  |  Size: 271 B

After

Width:  |  Height:  |  Size: 271 B

View File

Before

Width:  |  Height:  |  Size: 445 B

After

Width:  |  Height:  |  Size: 445 B

View File

Before

Width:  |  Height:  |  Size: 3.4 KiB

After

Width:  |  Height:  |  Size: 3.4 KiB

View File

Before

Width:  |  Height:  |  Size: 6.3 KiB

After

Width:  |  Height:  |  Size: 6.3 KiB

View File

Before

Width:  |  Height:  |  Size: 445 B

After

Width:  |  Height:  |  Size: 445 B

View File

Before

Width:  |  Height:  |  Size: 780 B

After

Width:  |  Height:  |  Size: 780 B

View File

Before

Width:  |  Height:  |  Size: 6.3 KiB

After

Width:  |  Height:  |  Size: 6.3 KiB

View File

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 16 KiB

View File

@ -3,6 +3,7 @@ import PackageDescription
let package = Package( let package = Package(
name: "MiniPlayer", name: "MiniPlayer",
defaultLocalization: "en",
platforms: [ platforms: [
.iOS(.v17), .iOS(.v17),
.macOS(.v14) .macOS(.v14)
@ -14,7 +15,8 @@ let package = Package(
targets: [ targets: [
.executableTarget( .executableTarget(
name: "MiniPlayer", name: "MiniPlayer",
dependencies: [] dependencies: [],
resources: [.process("Resources")]
) )
] ]
) )

View File

@ -1,5 +1,6 @@
import SwiftUI import SwiftUI
import AVFoundation import AVFoundation
import Combine
@main @main
struct MiniPlayerApp: App { struct MiniPlayerApp: App {
@ -14,31 +15,31 @@ struct MiniPlayerApp: App {
#if os(macOS) #if os(macOS)
.commands { .commands {
CommandGroup(replacing: .newItem) {} CommandGroup(replacing: .newItem) {}
CommandMenu("播放") { CommandMenu(L.playback) {
Button("播放/暂停") { bridge.togglePlayPause() } Button(L.playPause) { bridge.togglePlayPause() }
.keyboardShortcut(" ", modifiers: []) .keyboardShortcut(" ", modifiers: [])
Divider() Divider()
Button("上一首") { bridge.playPrev() } Button(L.prev) { bridge.playPrev() }
.keyboardShortcut("[", modifiers: []) .keyboardShortcut("[", modifiers: [])
Button("下一首") { bridge.playNext() } Button(L.next) { bridge.playNext() }
.keyboardShortcut("]", modifiers: []) .keyboardShortcut("]", modifiers: [])
Divider() Divider()
Button("全屏") { bridge.toggleFullscreen() } Button(L.fullscreen) { bridge.toggleFullscreen() }
.keyboardShortcut("f", modifiers: .command) .keyboardShortcut("f", modifiers: .command)
Divider() Divider()
Button("音量+") { bridge.adjustVolume(by: 0.1) } Button(L.volUp) { bridge.adjustVolume(by: 0.1) }
.keyboardShortcut("=", modifiers: .command) .keyboardShortcut("=", modifiers: .command)
Button("音量-") { bridge.adjustVolume(by: -0.1) } Button(L.volDown) { bridge.adjustVolume(by: -0.1) }
.keyboardShortcut("-", modifiers: .command) .keyboardShortcut("-", modifiers: .command)
Divider() Divider()
Button("循环模式") { bridge.cycleRepeatMode() } Button(L.repeatMode) { bridge.cycleRepeatMode() }
.keyboardShortcut("r", modifiers: .command) .keyboardShortcut("r", modifiers: .command)
} }
CommandMenu("文件") { CommandMenu(L.file) {
Button("打开文件...") { bridge.openFileDialog() } Button(L.openFile) { bridge.openFileDialog() }
.keyboardShortcut("o", modifiers: .command) .keyboardShortcut("o", modifiers: .command)
Divider() Divider()
Button("添加URL...") { bridge.showURLDialog = true } Button(L.addURL) { bridge.showURLDialog = true }
.keyboardShortcut("u", modifiers: .command) .keyboardShortcut("u", modifiers: .command)
} }
} }
@ -46,9 +47,15 @@ struct MiniPlayerApp: App {
} }
} }
// MARK: - SwiftUIBricksView // MARK: -
struct ContentView: View { struct ContentView: View {
@ObservedObject var bridge: PlayerBridge @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 { var body: some View {
ZStack { ZStack {
@ -61,7 +68,10 @@ struct ContentView: View {
// / + // / +
Color.clear Color.clear
.contentShape(Rectangle()) .contentShape(Rectangle())
.onTapGesture { bridge.togglePlayPause() } .onTapGesture {
bridge.togglePlayPause()
touchInteraction()
}
#if os(macOS) #if os(macOS)
.onTapGesture(count: 2) { bridge.toggleFullscreen() } .onTapGesture(count: 2) { bridge.toggleFullscreen() }
#endif #endif
@ -70,10 +80,13 @@ struct ContentView: View {
// Logo // Logo
VStack { VStack {
HStack { HStack {
Button(action: { bridge.showToolbar.toggle() }) { Button(action: {
Text("🎬") toolbarVisible.toggle()
.font(.system(size: 28)) if toolbarVisible { touchInteraction() }
.padding(8) }) {
MiniPlayerIcon()
.frame(width: 36, height: 36)
.padding(6)
.background(.black.opacity(0.4)) .background(.black.opacity(0.4))
.cornerRadius(10) .cornerRadius(10)
} }
@ -85,11 +98,11 @@ struct ContentView: View {
Spacer() Spacer()
} }
// Toolbarlogo // Toolbar
if bridge.showToolbar { if toolbarVisible {
VStack { VStack {
Spacer() Spacer()
ControlToolbar(bridge: bridge) ControlToolbar(bridge: bridge, isHovering: $isHoveringToolbar, onTouch: touchInteraction)
.transition(.move(edge: .bottom).combined(with: .opacity)) .transition(.move(edge: .bottom).combined(with: .opacity))
} }
} }
@ -116,19 +129,33 @@ struct ContentView: View {
.sheet(isPresented: $bridge.showPlaylistSheet) { .sheet(isPresented: $bridge.showPlaylistSheet) {
PlaylistWindowView(bridge: bridge) PlaylistWindowView(bridge: bridge)
} }
.animation(.easeInOut(duration: 0.25), value: bridge.showToolbar) .animation(.easeInOut(duration: 0.25), value: toolbarVisible)
// Timer2
.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 .onReceive(NotificationCenter.default.publisher(for: .init("ExitFullscreen"))) { _ in
bridge.toggleFullscreen() DispatchQueue.main.async { [bridge] in
} bridge.toggleFullscreen()
.onDisappear { }
bridge.cleanup()
} }
} }
private func touchInteraction() {
lastInteraction = Date()
}
} }
// MARK: - // MARK: -
struct ControlToolbar: View { struct ControlToolbar: View {
@ObservedObject var bridge: PlayerBridge @ObservedObject var bridge: PlayerBridge
@Binding var isHovering: Bool
let onTouch: () -> Void
var body: some View { var body: some View {
VStack(spacing: 6) { VStack(spacing: 6) {
@ -151,16 +178,16 @@ struct ControlToolbar: View {
// //
HStack(spacing: 10) { HStack(spacing: 10) {
#if os(macOS) #if os(macOS)
ToolbarButton(label: "📂 打开") { bridge.openFileDialog() } ToolbarButton(label: "📂 \(L.open)") { bridge.openFileDialog(); onTouch() }
#endif #endif
ToolbarButton(label: "") { bridge.playPrev() } ToolbarButton(label: "") { bridge.playPrev(); onTouch() }
ToolbarButton(label: bridge.isPlaying ? "" : "▶️") { bridge.togglePlayPause() } ToolbarButton(label: bridge.isPlaying ? "" : "▶️") { bridge.togglePlayPause(); onTouch() }
ToolbarButton(label: "") { bridge.playNext() } ToolbarButton(label: "") { bridge.playNext(); onTouch() }
Spacer() Spacer()
// //
ToolbarButton(label: "🔈") { bridge.adjustVolume(by: -0.1) } ToolbarButton(label: "🔈") { bridge.adjustVolume(by: -0.1); onTouch() }
// //
GeometryReader { geo in GeometryReader { geo in
@ -178,6 +205,7 @@ struct ControlToolbar: View {
.onChanged { value in .onChanged { value in
let ratio = max(0, min(1, value.location.x / geo.size.width)) let ratio = max(0, min(1, value.location.x / geo.size.width))
bridge.setVolume(Float(ratio)) bridge.setVolume(Float(ratio))
onTouch()
} }
) )
} }
@ -188,7 +216,7 @@ struct ControlToolbar: View {
.foregroundColor(.white.opacity(0.7)) .foregroundColor(.white.opacity(0.7))
.frame(width: 30) .frame(width: 30)
ToolbarButton(label: "🔊") { bridge.adjustVolume(by: 0.1) } ToolbarButton(label: "🔊") { bridge.adjustVolume(by: 0.1); onTouch() }
Spacer() Spacer()
@ -196,20 +224,19 @@ struct ControlToolbar: View {
.font(.system(size: 11)) .font(.system(size: 11))
.foregroundColor(.white.opacity(0.7)) .foregroundColor(.white.opacity(0.7))
ToolbarButton(label: "🎵 音轨") { bridge.showTrackDialog = true } ToolbarButton(label: L.audioTrack) { bridge.showTrackDialog = true; onTouch() }
ToolbarButton(label: "\(bridge.repeatMode.icon) \(bridge.repeatMode.rawValue)") { bridge.cycleRepeatMode() } ToolbarButton(label: "\(bridge.repeatMode.icon) \(bridge.repeatMode.displayName)") { bridge.cycleRepeatMode(); onTouch() }
ToolbarButton(label: "") { bridge.toggleFullscreen() } ToolbarButton(label: "") { bridge.toggleFullscreen(); onTouch() }
ToolbarButton(label: "📋 列表 (\(bridge.queue.count))") { bridge.togglePlaylistWindow() } ToolbarButton(label: "📋 \(L.list) (\(bridge.queue.count))") { bridge.togglePlaylistWindow(); onTouch() }
} }
} }
.padding(.horizontal, 16) .padding(.horizontal, 16)
.padding(.vertical, 8) .padding(.vertical, 8)
.background(.black.opacity(0.55)) .background(.black.opacity(0.55))
.onHover { hovering in .onHover { hovering in
bridge.isInteracting = hovering isHovering = hovering
if hovering { bridge.recordInteraction() } if hovering { onTouch() }
} }
.onTapGesture { bridge.recordInteraction() }
} }
} }
@ -241,10 +268,10 @@ struct PlaylistWindowView: View {
var body: some View { var body: some View {
VStack(spacing: 0) { VStack(spacing: 0) {
HStack { HStack {
Button("📂 添加文件") { bridge.openFileDialog() } Button(L.addFile) { bridge.openFileDialog() }
Button("🔗 添加URL") { bridge.showURLDialog = true } Button(L.addURLBtn) { bridge.showURLDialog = true }
Spacer() Spacer()
Text("\(bridge.queue.count) ") Text("\(bridge.queue.count) \(L.itemsCount)")
.font(.caption).foregroundColor(.secondary) .font(.caption).foregroundColor(.secondary)
} }
.padding(12) .padding(12)
@ -253,7 +280,7 @@ struct PlaylistWindowView: View {
Divider() Divider()
if bridge.queue.isEmpty { if bridge.queue.isEmpty {
VStack { Spacer(); Text("暂无媒体").foregroundColor(.secondary); Spacer() } VStack { Spacer(); Text(L.noMedia).foregroundColor(.secondary); Spacer() }
} else { } else {
ScrollView { ScrollView {
LazyVStack(spacing: 2) { LazyVStack(spacing: 2) {
@ -292,13 +319,13 @@ struct URLInputDialog: View {
var body: some View { var body: some View {
VStack(spacing: 16) { VStack(spacing: 16) {
Text("添加媒体URL").font(.headline) Text(L.addMediaURL).font(.headline)
TextField("https://example.com/video.m3u8", text: $url) TextField("https://example.com/video.m3u8", text: $url)
.textFieldStyle(.roundedBorder) .textFieldStyle(.roundedBorder)
HStack { HStack {
Button("取消") { dismiss() } Button(L.cancel) { dismiss() }
Spacer() Spacer()
Button("添加") { Button(L.add) {
if !url.isEmpty { bridge.addURL(url); dismiss() } if !url.isEmpty { bridge.addURL(url); dismiss() }
} }
.keyboardShortcut(.defaultAction) .keyboardShortcut(.defaultAction)
@ -317,11 +344,11 @@ struct TrackSelectDialog: View {
var body: some View { var body: some View {
VStack(spacing: 12) { VStack(spacing: 12) {
Text("选择音轨").font(.headline) Text(L.selectTrack).font(.headline)
ForEach(Array(bridge.availableTracks.enumerated()), id: \.offset) { idx, track in ForEach(Array(bridge.availableTracks.enumerated()), id: \.offset) { idx, track in
Button(action: { bridge.selectTrack(index: idx); dismiss() }) { Button(action: { bridge.selectTrack(index: idx); dismiss() }) {
HStack { HStack {
Text("Track \(idx + 1)") Text("\(L.track) \(idx + 1)")
Spacer() Spacer()
if idx == bridge.currentTrackIndex { if idx == bridge.currentTrackIndex {
Text("").foregroundColor(.green) Text("").foregroundColor(.green)
@ -331,7 +358,7 @@ struct TrackSelectDialog: View {
}.buttonStyle(.plain) }.buttonStyle(.plain)
} }
Divider() Divider()
Button("关闭") { dismiss() } Button(L.close) { dismiss() }
} }
.padding(20) .padding(20)
.frame(minWidth: 250) .frame(minWidth: 250)

View File

@ -16,9 +16,17 @@ struct MediaItem: Identifiable, Equatable {
/// ///
enum RepeatMode: String, CaseIterable { enum RepeatMode: String, CaseIterable {
case none = "不循环" case none = "none"
case single = "单曲循环" case single = "single"
case all = "列表循环" 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 { var icon: String {
switch self { switch self {
@ -137,7 +145,7 @@ final class PlayerBridge: ObservableObject {
} }
loadTrackInfo(playerItem) loadTrackInfo(playerItem)
showToast("正在播放: \(item.name)") showToast(L.nowPlaying(item.name))
} }
// MARK: - // MARK: -
@ -246,13 +254,13 @@ final class PlayerBridge: ObservableObject {
} }
private func updateTrackLabel() { private func updateTrackLabel() {
currentTrackLabel = "🎵 Track \(currentTrackIndex + 1)/\(max(availableTracks.count, 1))" currentTrackLabel = "🎵 \(L.track) \(currentTrackIndex + 1)/\(max(availableTracks.count, 1))"
} }
// MARK: - // MARK: -
func cycleRepeatMode() { func cycleRepeatMode() {
repeatMode = repeatMode.next repeatMode = repeatMode.next
showToast("循环模式: \(repeatMode.rawValue)") showToast(L.repeatModeLabel(repeatMode.displayName))
} }
// MARK: - // MARK: -
@ -305,7 +313,7 @@ final class PlayerBridge: ObservableObject {
} }
func addURL(_ urlString: String) { 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 name = url.lastPathComponent.isEmpty ? (url.host ?? urlString) : url.lastPathComponent
let type = urlString.contains(".m3u8") ? "stream" : "url" let type = urlString.contains(".m3u8") ? "stream" : "url"
addItem(url: url, name: name, type: type) 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), contentRect: NSRect(x: 0, y: 0, width: 400, height: 500),
styleMask: [.titled, .closable, .resizable], backing: .buffered, defer: false styleMask: [.titled, .closable, .resizable], backing: .buffered, defer: false
) )
win.title = "播放列表 (\(queue.count))" win.title = "\(L.playlist) (\(queue.count))"
win.isReleasedWhenClosed = false win.isReleasedWhenClosed = false
win.center() win.center()
win.contentView = NSHostingView(rootView: PlaylistWindowView(bridge: self)) win.contentView = NSHostingView(rootView: PlaylistWindowView(bridge: self))