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(
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")]
)
]
)

View File

@ -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: - SwiftUIBricksView
// 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()
}
// Toolbarlogo
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)
// 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
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)

View File

@ -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))