Compare commits
15 Commits
04d23bec1e
...
3.2
| Author | SHA1 | Date | |
|---|---|---|---|
| 1c9916c69d | |||
| cfd023be69 | |||
| 719359b940 | |||
| f4d0bd7ca0 | |||
| 26a13c27d6 | |||
| cfe5063828 | |||
| 69742ce2d9 | |||
| 5b3787f19f | |||
| 9b0f6e2123 | |||
| 2d4eae9676 | |||
| 848c4c2b55 | |||
| a6ca763730 | |||
| 0916de60f3 | |||
| cfc6e6c411 | |||
| bc54735d1f |
6
.gitignore
vendored
Normal file
6
.gitignore
vendored
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
/.env
|
||||||
|
ios/build/
|
||||||
|
fastlane/report.xml
|
||||||
|
fastlane/Preview.html
|
||||||
|
fastlane/screenshots/
|
||||||
|
fastlane/test_output/
|
||||||
6
fastlane/Appfile
Normal file
6
fastlane/Appfile
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
apple_id("james.magahern@mac.com")
|
||||||
|
app_identifier("net.buzzert.QueueCube")
|
||||||
|
team_id("DQQH5H6GBD")
|
||||||
|
team_name("James Magahern")
|
||||||
|
itc_team_id("127764897")
|
||||||
|
itc_team_name("James Magahern")
|
||||||
129
fastlane/Fastfile
Normal file
129
fastlane/Fastfile
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
require "fileutils"
|
||||||
|
require "open3"
|
||||||
|
require "shellwords"
|
||||||
|
require "tempfile"
|
||||||
|
|
||||||
|
default_platform(:ios)
|
||||||
|
|
||||||
|
BUNDLE_IDENTIFIER = "net.buzzert.QueueCube"
|
||||||
|
DEVELOPMENT_TEAM = "DQQH5H6GBD"
|
||||||
|
APP_ROOT = File.expand_path("..", File.expand_path(__dir__))
|
||||||
|
IOS_PROJECT_DIR = File.join(APP_ROOT, "ios")
|
||||||
|
XCODE_PROJECT = File.join(IOS_PROJECT_DIR, "QueueCube.xcodeproj")
|
||||||
|
SCHEME = "QueueCube"
|
||||||
|
ARCHIVE_PATH = File.join(IOS_PROJECT_DIR, "build", "#{SCHEME}.xcarchive")
|
||||||
|
EXPORT_PATH = File.join(IOS_PROJECT_DIR, "build", "upload")
|
||||||
|
|
||||||
|
def shell_command(*parts)
|
||||||
|
parts.flatten.map { |part| part.to_s.shellescape }.join(" ")
|
||||||
|
end
|
||||||
|
|
||||||
|
def archive_path
|
||||||
|
ARCHIVE_PATH
|
||||||
|
end
|
||||||
|
|
||||||
|
def export_path
|
||||||
|
EXPORT_PATH
|
||||||
|
end
|
||||||
|
|
||||||
|
def git_output(*args)
|
||||||
|
stdout, stderr, status = Open3.capture3("git", *args, chdir: APP_ROOT)
|
||||||
|
UI.user_error!("git #{args.join(' ')} failed: #{stderr.strip}") unless status.success?
|
||||||
|
|
||||||
|
stdout.strip
|
||||||
|
end
|
||||||
|
|
||||||
|
def app_version
|
||||||
|
tag = git_output("describe", "--tags", "--abbrev=0")
|
||||||
|
version = tag.sub(/\Av/, "")
|
||||||
|
unless version.match?(/\A\d+(?:\.\d+){0,2}\z/)
|
||||||
|
UI.user_error!("Latest git tag #{tag.inspect} is not a valid App Store version. Use a tag like 1.5.2 or v1.5.2.")
|
||||||
|
end
|
||||||
|
|
||||||
|
version
|
||||||
|
end
|
||||||
|
|
||||||
|
def build_number
|
||||||
|
git_output("rev-list", "--count", "HEAD")
|
||||||
|
end
|
||||||
|
|
||||||
|
def upload_export_options
|
||||||
|
<<~PLIST
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>method</key>
|
||||||
|
<string>app-store-connect</string>
|
||||||
|
<key>signingStyle</key>
|
||||||
|
<string>automatic</string>
|
||||||
|
<key>teamID</key>
|
||||||
|
<string>#{DEVELOPMENT_TEAM}</string>
|
||||||
|
<key>manageAppVersionAndBuildNumber</key>
|
||||||
|
<false/>
|
||||||
|
<key>stripSwiftSymbols</key>
|
||||||
|
<true/>
|
||||||
|
<key>uploadSymbols</key>
|
||||||
|
<true/>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
|
PLIST
|
||||||
|
end
|
||||||
|
|
||||||
|
platform :ios do
|
||||||
|
desc "Build QueueCube for iOS and upload the archive to TestFlight"
|
||||||
|
lane :beta do
|
||||||
|
version = app_version
|
||||||
|
build = build_number
|
||||||
|
UI.message("Using QueueCube version #{version} (build #{build}) from git")
|
||||||
|
|
||||||
|
FileUtils.rm_rf(archive_path)
|
||||||
|
FileUtils.rm_rf(export_path)
|
||||||
|
|
||||||
|
Dir.chdir(IOS_PROJECT_DIR) do
|
||||||
|
sh(shell_command(
|
||||||
|
"xcodebuild",
|
||||||
|
"-project", XCODE_PROJECT,
|
||||||
|
"-scheme", SCHEME,
|
||||||
|
"-configuration", "Release",
|
||||||
|
"-destination", "generic/platform=iOS",
|
||||||
|
"-archivePath", archive_path,
|
||||||
|
"-allowProvisioningUpdates",
|
||||||
|
"clean",
|
||||||
|
"archive",
|
||||||
|
"DEVELOPMENT_TEAM=#{DEVELOPMENT_TEAM}",
|
||||||
|
"PRODUCT_BUNDLE_IDENTIFIER=#{BUNDLE_IDENTIFIER}",
|
||||||
|
"MARKETING_VERSION=#{version}",
|
||||||
|
"CURRENT_PROJECT_VERSION=#{build}"
|
||||||
|
))
|
||||||
|
end
|
||||||
|
|
||||||
|
export_options = Tempfile.new(["queuecube-export-options", ".plist"])
|
||||||
|
export_options.write(upload_export_options)
|
||||||
|
export_options.close
|
||||||
|
|
||||||
|
FileUtils.rm_rf(export_path)
|
||||||
|
Dir.chdir(IOS_PROJECT_DIR) do
|
||||||
|
sh(shell_command(
|
||||||
|
"xcodebuild",
|
||||||
|
"-exportArchive",
|
||||||
|
"-archivePath", archive_path,
|
||||||
|
"-exportPath", export_path,
|
||||||
|
"-exportOptionsPlist", export_options.path,
|
||||||
|
"-allowProvisioningUpdates"
|
||||||
|
))
|
||||||
|
end
|
||||||
|
|
||||||
|
ipa_path = Dir[File.join(export_path, "*.ipa")].first
|
||||||
|
UI.user_error!("No IPA found in #{export_path}") unless ipa_path
|
||||||
|
|
||||||
|
upload_to_testflight(
|
||||||
|
app_identifier: BUNDLE_IDENTIFIER,
|
||||||
|
ipa: ipa_path,
|
||||||
|
skip_waiting_for_build_processing: true,
|
||||||
|
uses_non_exempt_encryption: false
|
||||||
|
)
|
||||||
|
ensure
|
||||||
|
export_options&.unlink
|
||||||
|
end
|
||||||
|
end
|
||||||
32
fastlane/README.md
Normal file
32
fastlane/README.md
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
fastlane documentation
|
||||||
|
----
|
||||||
|
|
||||||
|
# Installation
|
||||||
|
|
||||||
|
Make sure you have the latest version of the Xcode command line tools installed:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
xcode-select --install
|
||||||
|
```
|
||||||
|
|
||||||
|
For _fastlane_ installation instructions, see [Installing _fastlane_](https://docs.fastlane.tools/#installing-fastlane)
|
||||||
|
|
||||||
|
# Available Actions
|
||||||
|
|
||||||
|
## iOS
|
||||||
|
|
||||||
|
### ios beta
|
||||||
|
|
||||||
|
```sh
|
||||||
|
[bundle exec] fastlane ios beta
|
||||||
|
```
|
||||||
|
|
||||||
|
Build QueueCube for iOS and upload the archive to TestFlight
|
||||||
|
|
||||||
|
----
|
||||||
|
|
||||||
|
This README.md is auto-generated and will be re-generated every time [_fastlane_](https://fastlane.tools) is run.
|
||||||
|
|
||||||
|
More information about _fastlane_ can be found on [fastlane.tools](https://fastlane.tools).
|
||||||
|
|
||||||
|
The documentation of _fastlane_ can be found on [docs.fastlane.tools](https://docs.fastlane.tools).
|
||||||
@@ -269,7 +269,7 @@
|
|||||||
CODE_SIGN_IDENTITY = "Apple Development";
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development";
|
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 10;
|
CURRENT_PROJECT_VERSION = 13;
|
||||||
DEVELOPMENT_TEAM = DQQH5H6GBD;
|
DEVELOPMENT_TEAM = DQQH5H6GBD;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
@@ -284,7 +284,7 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.5;
|
MARKETING_VERSION = 1.5.2;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = net.buzzert.QueueCube;
|
PRODUCT_BUNDLE_IDENTIFIER = net.buzzert.QueueCube;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||||
@@ -305,7 +305,7 @@
|
|||||||
CODE_SIGN_IDENTITY = "Apple Development";
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development";
|
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 10;
|
CURRENT_PROJECT_VERSION = 13;
|
||||||
DEVELOPMENT_TEAM = DQQH5H6GBD;
|
DEVELOPMENT_TEAM = DQQH5H6GBD;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
@@ -320,7 +320,7 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.5;
|
MARKETING_VERSION = 1.5.2;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = net.buzzert.QueueCube;
|
PRODUCT_BUNDLE_IDENTIFIER = net.buzzert.QueueCube;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ struct MediaItem: Codable
|
|||||||
let current: Bool?
|
let current: Bool?
|
||||||
let playing: Bool?
|
let playing: Bool?
|
||||||
let metadata: Metadata?
|
let metadata: Metadata?
|
||||||
|
let playbackError: String?
|
||||||
|
|
||||||
var displayTitle: String {
|
var displayTitle: String {
|
||||||
metadata?.title ?? title ?? displayFilename ?? "item \(id)"
|
metadata?.title ?? title ?? displayFilename ?? "item \(id)"
|
||||||
@@ -280,6 +281,7 @@ actor API
|
|||||||
case favoritesUpdate = "favorites_update"
|
case favoritesUpdate = "favorites_update"
|
||||||
case metadataUpdate = "metadata_update"
|
case metadataUpdate = "metadata_update"
|
||||||
case mpdUpdate = "mpd_update"
|
case mpdUpdate = "mpd_update"
|
||||||
|
case playbackError = "playback_error"
|
||||||
|
|
||||||
// Private UI events
|
// Private UI events
|
||||||
case receivedWebsocketPong
|
case receivedWebsocketPong
|
||||||
|
|||||||
@@ -104,6 +104,16 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"DELETE" : {
|
||||||
|
"localizations" : {
|
||||||
|
"en" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Delete"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"DISCOVERED" : {
|
"DISCOVERED" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"en" : {
|
"en" : {
|
||||||
@@ -247,6 +257,16 @@
|
|||||||
},
|
},
|
||||||
"Nothing here yet." : {
|
"Nothing here yet." : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"PLAYBACK_ERROR" : {
|
||||||
|
"localizations" : {
|
||||||
|
"en" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Playback Error"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"PLAYLIST" : {
|
"PLAYLIST" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
@@ -360,6 +380,16 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Unknown error" : {
|
||||||
|
"localizations" : {
|
||||||
|
"en" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Unknown Error"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"URL" : {
|
"URL" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"en" : {
|
"en" : {
|
||||||
|
|||||||
@@ -46,4 +46,6 @@ extension LocalizedStringKey
|
|||||||
static let notPlaying = LocalizedStringKey("NOT_PLAYING")
|
static let notPlaying = LocalizedStringKey("NOT_PLAYING")
|
||||||
static let url = LocalizedStringKey("URL")
|
static let url = LocalizedStringKey("URL")
|
||||||
static let title = LocalizedStringKey("TITLE")
|
static let title = LocalizedStringKey("TITLE")
|
||||||
|
static let playbackError = LocalizedStringKey("PLAYBACK_ERROR")
|
||||||
|
static let delete = LocalizedStringKey("DELETE")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,6 +29,8 @@ struct AutofocusingTextField: UIViewRepresentable
|
|||||||
tf.delegate = context.coordinator
|
tf.delegate = context.coordinator
|
||||||
tf.returnKeyType = .done
|
tf.returnKeyType = .done
|
||||||
tf.setContentHuggingPriority(.defaultHigh, for: .vertical)
|
tf.setContentHuggingPriority(.defaultHigh, for: .vertical)
|
||||||
|
tf.autocorrectionType = .no
|
||||||
|
tf.autocapitalizationType = .none
|
||||||
return tf
|
return tf
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -87,7 +87,8 @@ extension ContentView
|
|||||||
title: mediaItem.displayTitle,
|
title: mediaItem.displayTitle,
|
||||||
filename: mediaItem.filename ?? "<null>",
|
filename: mediaItem.filename ?? "<null>",
|
||||||
index: idx,
|
index: idx,
|
||||||
isCurrent: mediaItem.current ?? false
|
isCurrent: mediaItem.current ?? false,
|
||||||
|
playbackError: mediaItem.playbackError
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -100,7 +101,8 @@ extension ContentView
|
|||||||
id: String(mediaItem.id),
|
id: String(mediaItem.id),
|
||||||
title: mediaItem.displayTitle,
|
title: mediaItem.displayTitle,
|
||||||
filename: mediaItem.filename ?? "<null>",
|
filename: mediaItem.filename ?? "<null>",
|
||||||
isCurrent: nowPlaying.playingItem?.filename == mediaItem.filename
|
isCurrent: nowPlaying.playingItem?.filename == mediaItem.filename,
|
||||||
|
playbackError: mediaItem.playbackError
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -160,7 +162,8 @@ extension ContentView
|
|||||||
|
|
||||||
case .websocketReconnected: fallthrough
|
case .websocketReconnected: fallthrough
|
||||||
case .metadataUpdate: fallthrough
|
case .metadataUpdate: fallthrough
|
||||||
case .mpdUpdate:
|
case .mpdUpdate: fallthrough
|
||||||
|
case .playbackError:
|
||||||
await refresh([.playlist, .nowPlaying, .favorites])
|
await refresh([.playlist, .nowPlaying, .favorites])
|
||||||
|
|
||||||
case .receivedWebsocketPong:
|
case .receivedWebsocketPong:
|
||||||
|
|||||||
@@ -14,17 +14,19 @@ struct MediaListItem: Identifiable
|
|||||||
let filename: String
|
let filename: String
|
||||||
let index: Int?
|
let index: Int?
|
||||||
let isCurrent: Bool
|
let isCurrent: Bool
|
||||||
|
let playbackError: String?
|
||||||
|
|
||||||
var id: String {
|
var id: String {
|
||||||
_id + filename // temporary: we get duplicate ids from the server sometimes...
|
_id + filename // temporary: we get duplicate ids from the server sometimes...
|
||||||
}
|
}
|
||||||
|
|
||||||
init(id: String, title: String, filename: String, index: Int? = nil, isCurrent: Bool = false) {
|
init(id: String, title: String, filename: String, index: Int? = nil, isCurrent: Bool = false, playbackError: String? = nil) {
|
||||||
self._id = id
|
self._id = id
|
||||||
self.title = title
|
self.title = title
|
||||||
self.filename = filename
|
self.filename = filename
|
||||||
self.index = index
|
self.index = index
|
||||||
self.isCurrent = isCurrent
|
self.isCurrent = isCurrent
|
||||||
|
self.playbackError = playbackError
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,6 +47,7 @@ class MediaListViewModel
|
|||||||
var onQueue: (MediaListItem) -> Void = { _ in }
|
var onQueue: (MediaListItem) -> Void = { _ in }
|
||||||
var onEdit: (MediaListItem) -> Void = { _ in }
|
var onEdit: (MediaListItem) -> Void = { _ in }
|
||||||
var onFavorite: (MediaListItem) -> Void = { _ in }
|
var onFavorite: (MediaListItem) -> Void = { _ in }
|
||||||
|
var onDelete: (MediaListItem) -> Void = { _ in }
|
||||||
|
|
||||||
init(mode: MediaListMode) {
|
init(mode: MediaListMode) {
|
||||||
self.mode = mode
|
self.mode = mode
|
||||||
@@ -54,6 +57,8 @@ class MediaListViewModel
|
|||||||
struct MediaListView: View
|
struct MediaListView: View
|
||||||
{
|
{
|
||||||
@Binding var model: MediaListViewModel
|
@Binding var model: MediaListViewModel
|
||||||
|
@State private var errorAlertItem: MediaListItem? = nil
|
||||||
|
@State private var isShowingErrorAlert: Bool = false
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack {
|
VStack {
|
||||||
@@ -70,20 +75,29 @@ struct MediaListView: View
|
|||||||
let state = item.isCurrent ? (model.isPlaying ? MediaItemCell.State.playing : MediaItemCell.State.paused) : .queued
|
let state = item.isCurrent ? (model.isPlaying ? MediaItemCell.State.playing : MediaItemCell.State.paused) : .queued
|
||||||
|
|
||||||
Button {
|
Button {
|
||||||
|
if let _ = item.playbackError {
|
||||||
|
errorAlertItem = item
|
||||||
|
isShowingErrorAlert = true
|
||||||
|
} else {
|
||||||
switch model.mode {
|
switch model.mode {
|
||||||
case .playlist:
|
case .playlist:
|
||||||
model.onSeek(item)
|
model.onSeek(item)
|
||||||
case .favorites:
|
case .favorites:
|
||||||
model.onPlay(item)
|
model.onPlay(item)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
} label: {
|
} label: {
|
||||||
MediaItemCell(
|
MediaItemCell(
|
||||||
title: item.title,
|
title: item.title,
|
||||||
subtitle: item.filename,
|
subtitle: item.filename,
|
||||||
state: state
|
state: state,
|
||||||
|
playbackError: item.playbackError
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
.listRowBackground((model.mode == .playlist && state != .queued) ? Color.accentColor.opacity(0.10) : nil)
|
.listRowBackground(
|
||||||
|
item.playbackError != nil ? Color.red.opacity(0.15) :
|
||||||
|
(model.mode == .playlist && state != .queued ? Color.accentColor.opacity(0.10) : nil)
|
||||||
|
)
|
||||||
.contextMenu {
|
.contextMenu {
|
||||||
Button(.copyTitle) {
|
Button(.copyTitle) {
|
||||||
UIPasteboard.general.string = item.title
|
UIPasteboard.general.string = item.title
|
||||||
@@ -123,6 +137,20 @@ struct MediaListView: View
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.alert(.playbackError, isPresented: $isShowingErrorAlert, presenting: errorAlertItem) { item in
|
||||||
|
Button(.cancel, role: .cancel) {
|
||||||
|
errorAlertItem = nil
|
||||||
|
isShowingErrorAlert = false
|
||||||
|
}
|
||||||
|
Button(.delete, role: .destructive) {
|
||||||
|
model.items.removeAll { $0.id == item.id }
|
||||||
|
model.onDelete(item)
|
||||||
|
errorAlertItem = nil
|
||||||
|
isShowingErrorAlert = false
|
||||||
|
}
|
||||||
|
} message: { item in
|
||||||
|
Text(item.playbackError ?? "Unknown error")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -134,17 +162,12 @@ struct MediaItemCell: View
|
|||||||
let title: String
|
let title: String
|
||||||
let subtitle: String
|
let subtitle: String
|
||||||
let state: State
|
let state: State
|
||||||
|
let playbackError: String?
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
let icon: String = switch state {
|
|
||||||
case .queued: "play.fill"
|
|
||||||
case .playing: "speaker.wave.3.fill"
|
|
||||||
case .paused: "speaker.fill"
|
|
||||||
}
|
|
||||||
|
|
||||||
HStack {
|
HStack {
|
||||||
Image(systemName: icon)
|
Image(systemName: iconName)
|
||||||
.tint(Color.primary)
|
.tint(playbackError == nil ? Color.primary : Color.orange)
|
||||||
.frame(width: 15.0)
|
.frame(width: 15.0)
|
||||||
.padding(.trailing, 10.0)
|
.padding(.trailing, 10.0)
|
||||||
|
|
||||||
@@ -164,6 +187,19 @@ struct MediaItemCell: View
|
|||||||
Spacer()
|
Spacer()
|
||||||
}
|
}
|
||||||
.padding([.top, .bottom], 4.0)
|
.padding([.top, .bottom], 4.0)
|
||||||
|
.frame(minHeight: 44.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var iconName: String {
|
||||||
|
if playbackError != nil {
|
||||||
|
return "exclamationmark.triangle.fill"
|
||||||
|
}
|
||||||
|
|
||||||
|
switch state {
|
||||||
|
case .queued: return "play.fill"
|
||||||
|
case .playing: return "speaker.wave.3.fill"
|
||||||
|
case .paused: return "speaker.fill"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Types
|
// MARK: - Types
|
||||||
@@ -174,4 +210,3 @@ struct MediaItemCell: View
|
|||||||
case paused
|
case paused
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -30,6 +30,11 @@ interface PendingCommand {
|
|||||||
reject: (reason: any) => void;
|
reject: (reason: any) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum ObservedProperty {
|
||||||
|
MediaTitle = 1,
|
||||||
|
Metadata = 2,
|
||||||
|
}
|
||||||
|
|
||||||
enum UserEvent {
|
enum UserEvent {
|
||||||
PlaylistUpdate = "playlist_update",
|
PlaylistUpdate = "playlist_update",
|
||||||
NowPlayingUpdate = "now_playing_update",
|
NowPlayingUpdate = "now_playing_update",
|
||||||
@@ -37,6 +42,7 @@ enum UserEvent {
|
|||||||
FavoritesUpdate = "favorites_update",
|
FavoritesUpdate = "favorites_update",
|
||||||
MetadataUpdate = "metadata_update",
|
MetadataUpdate = "metadata_update",
|
||||||
MPDUpdate = "mpd_update",
|
MPDUpdate = "mpd_update",
|
||||||
|
PlaybackError = "playback_error",
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Features {
|
export interface Features {
|
||||||
@@ -57,6 +63,11 @@ export class MediaPlayer {
|
|||||||
private dataBuffer: string = '';
|
private dataBuffer: string = '';
|
||||||
private metadata: Map<string, LinkMetadata> = new Map();
|
private metadata: Map<string, LinkMetadata> = new Map();
|
||||||
private bonjourInstance: Bonjour | null = null;
|
private bonjourInstance: Bonjour | null = null;
|
||||||
|
private playbackErrors: Map<string, string> = new Map();
|
||||||
|
private currentFile: string | null = null;
|
||||||
|
private lastLoadCandidate: string | null = null;
|
||||||
|
private currentMediaTitle: string | null = null;
|
||||||
|
private currentPlaybackMetadata: Record<string, string> = {};
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.socket = this.tryRespawnPlayerProcess();
|
this.socket = this.tryRespawnPlayerProcess();
|
||||||
@@ -110,10 +121,9 @@ export class MediaPlayer {
|
|||||||
const socketFilename = Math.random().toString(36).substring(2, 10);
|
const socketFilename = Math.random().toString(36).substring(2, 10);
|
||||||
const socketPath = `/tmp/mpv-${socketFilename}`;
|
const socketPath = `/tmp/mpv-${socketFilename}`;
|
||||||
const enableVideo = process.env.ENABLE_VIDEO || false;
|
const enableVideo = process.env.ENABLE_VIDEO || false;
|
||||||
|
const ytdlFormat = process.env.MPV_YTDL_FORMAT;
|
||||||
const logfilePath = `/tmp/mpv-logfile.txt`;
|
const logfilePath = `/tmp/mpv-logfile.txt`;
|
||||||
|
const playerArgs = [
|
||||||
console.log("Starting player process (video: " + (enableVideo ? "enabled" : "disabled") + ")");
|
|
||||||
this.playerProcess = spawn("mpv", [
|
|
||||||
"--video=" + (enableVideo ? "auto" : "no"),
|
"--video=" + (enableVideo ? "auto" : "no"),
|
||||||
"--fullscreen",
|
"--fullscreen",
|
||||||
"--no-terminal",
|
"--no-terminal",
|
||||||
@@ -121,25 +131,38 @@ export class MediaPlayer {
|
|||||||
"--input-ipc-server=" + socketPath,
|
"--input-ipc-server=" + socketPath,
|
||||||
"--log-file=" + logfilePath,
|
"--log-file=" + logfilePath,
|
||||||
"--msg-level=all=v"
|
"--msg-level=all=v"
|
||||||
]);
|
];
|
||||||
|
|
||||||
|
if (ytdlFormat) {
|
||||||
|
playerArgs.push("--ytdl-format=" + ytdlFormat);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("Starting player process (video: " + (enableVideo ? "enabled" : "disabled") + ")");
|
||||||
|
this.playerProcess = spawn("mpv", playerArgs);
|
||||||
|
|
||||||
|
|
||||||
let socketReady!: (s: Socket) => void;
|
let socketReady!: (s: Socket) => void;
|
||||||
let socketPromise = new Promise<Socket>(resolve => {
|
let socketFailed!: (reason?: unknown) => void;
|
||||||
|
let socketPromise = new Promise<Socket>((resolve, reject) => {
|
||||||
socketReady = resolve;
|
socketReady = resolve;
|
||||||
|
socketFailed = reject;
|
||||||
});
|
});
|
||||||
|
|
||||||
this.playerProcess.on("spawn", () => {
|
this.playerProcess.on("spawn", () => {
|
||||||
console.log(`Player process spawned, opening socket @ ${socketPath}`);
|
console.log(`Player process spawned, opening socket @ ${socketPath}`);
|
||||||
setTimeout(() => {
|
this.connectToSocket(socketPath)
|
||||||
let socket = this.connectToSocket(socketPath);
|
.then(socket => socketReady(socket))
|
||||||
socketReady(socket);
|
.catch((error: unknown) => {
|
||||||
}, 500);
|
console.error(`Failed to connect to mpv socket @ ${socketPath}:`, error);
|
||||||
|
console.log("Continuing without mpv player...");
|
||||||
|
socketFailed(error);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
this.playerProcess.on("error", (error) => {
|
this.playerProcess.on("error", (error: unknown) => {
|
||||||
console.error("Player process error:", error);
|
console.error("Player process error:", error);
|
||||||
console.log("Continuing without mpv player...");
|
console.log("Continuing without mpv player...");
|
||||||
|
socketFailed(error);
|
||||||
});
|
});
|
||||||
|
|
||||||
return socketPromise;
|
return socketPromise;
|
||||||
@@ -150,10 +173,15 @@ export class MediaPlayer {
|
|||||||
.then((response) => {
|
.then((response) => {
|
||||||
// Enhance playlist items with metadata
|
// Enhance playlist items with metadata
|
||||||
const playlist = response.data as PlaylistItem[];
|
const playlist = response.data as PlaylistItem[];
|
||||||
return playlist.map((item: PlaylistItem) => ({
|
return playlist.map((item: PlaylistItem) => {
|
||||||
|
const enhancedItem = {
|
||||||
...item,
|
...item,
|
||||||
metadata: this.metadata.get(item.filename) || {}
|
metadata: this.metadata.get(item.filename) || {},
|
||||||
}));
|
playbackError: this.playbackErrors.get(item.filename)
|
||||||
|
};
|
||||||
|
|
||||||
|
return item.current ? this.withCurrentDynamicTitle(enhancedItem) : enhancedItem;
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -162,29 +190,37 @@ export class MediaPlayer {
|
|||||||
const currentlyPlayingSong = playlist.find((item: PlaylistItem) => item.current);
|
const currentlyPlayingSong = playlist.find((item: PlaylistItem) => item.current);
|
||||||
const fetchMediaTitle = async (): Promise<string | null> => {
|
const fetchMediaTitle = async (): Promise<string | null> => {
|
||||||
try {
|
try {
|
||||||
return (await this.writeCommand("get_property", ["media-title"])).data;
|
const mediaTitle = (await this.writeCommand("get_property", ["media-title"])).data;
|
||||||
|
this.currentMediaTitle = typeof mediaTitle === "string" ? mediaTitle : null;
|
||||||
|
return this.currentMediaTitle;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const mediaTitle = await fetchMediaTitle();
|
||||||
|
|
||||||
if (currentlyPlayingSong !== undefined) {
|
if (currentlyPlayingSong !== undefined) {
|
||||||
|
const dynamicTitle = this.getCurrentDynamicTitle(currentlyPlayingSong);
|
||||||
|
if (dynamicTitle) {
|
||||||
|
return this.withCurrentDynamicTitle(currentlyPlayingSong, dynamicTitle);
|
||||||
|
}
|
||||||
|
|
||||||
// Use media title if we don't have a title
|
// Use media title if we don't have a title
|
||||||
if (currentlyPlayingSong.title === undefined && currentlyPlayingSong.metadata?.title === undefined) {
|
if (currentlyPlayingSong.title === undefined && currentlyPlayingSong.metadata?.title === undefined) {
|
||||||
return {
|
return {
|
||||||
...currentlyPlayingSong,
|
...currentlyPlayingSong,
|
||||||
title: await fetchMediaTitle() || currentlyPlayingSong.filename
|
title: mediaTitle || currentlyPlayingSong.filename
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return currentlyPlayingSong;
|
return currentlyPlayingSong;
|
||||||
}
|
}
|
||||||
|
|
||||||
const mediaTitle = await fetchMediaTitle() || "";
|
|
||||||
return {
|
return {
|
||||||
id: 0,
|
id: 0,
|
||||||
filename: mediaTitle,
|
filename: mediaTitle || "",
|
||||||
title: mediaTitle
|
title: mediaTitle || ""
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -352,6 +388,8 @@ export class MediaPlayer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async loadFile(url: string, mode: string, fetchMetadata: boolean = true, options: string[] = []) {
|
private async loadFile(url: string, mode: string, fetchMetadata: boolean = true, options: string[] = []) {
|
||||||
|
this.lastLoadCandidate = url;
|
||||||
|
this.playbackErrors.delete(url);
|
||||||
this.modify(UserEvent.PlaylistUpdate, () => this.writeCommand("loadfile", [url, mode, "-1", options.join(',')]));
|
this.modify(UserEvent.PlaylistUpdate, () => this.writeCommand("loadfile", [url, mode, "-1", options.join(',')]));
|
||||||
|
|
||||||
if (fetchMetadata) {
|
if (fetchMetadata) {
|
||||||
@@ -390,7 +428,7 @@ export class MediaPlayer {
|
|||||||
socket.write(commandObject + '\n');
|
socket.write(commandObject + '\n');
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
console.error(`Error writing to socket: ${e}. Trying to respawn.`)
|
console.error(`Error writing to socket: ${e}. Trying to respawn.`)
|
||||||
this.tryRespawnPlayerProcess();
|
this.socket = this.tryRespawnPlayerProcess();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add timeout to prevent hanging promises
|
// Add timeout to prevent hanging promises
|
||||||
@@ -429,12 +467,52 @@ export class MediaPlayer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private connectToSocket(path: string): Socket {
|
private connectToSocket(path: string): Promise<Socket> {
|
||||||
let socket = new Socket();
|
const retryDelayMs = 100;
|
||||||
socket.connect(path);
|
const maxAttempts = 50;
|
||||||
socket.on("data", data => this.receiveData(data.toString()));
|
|
||||||
|
|
||||||
return socket;
|
return new Promise((resolve, reject) => {
|
||||||
|
type SocketError = Error & { code?: string };
|
||||||
|
|
||||||
|
const attemptConnection = (attempt: number) => {
|
||||||
|
const socket = new Socket();
|
||||||
|
|
||||||
|
const onError = (error: SocketError) => {
|
||||||
|
socket.removeAllListeners();
|
||||||
|
socket.destroy();
|
||||||
|
|
||||||
|
const shouldRetry = (error.code === "ENOENT" || error.code === "ECONNREFUSED") && attempt < maxAttempts;
|
||||||
|
if (shouldRetry) {
|
||||||
|
setTimeout(() => attemptConnection(attempt + 1), retryDelayMs);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
reject(error);
|
||||||
|
};
|
||||||
|
|
||||||
|
socket.once("connect", () => {
|
||||||
|
socket.removeListener("error", onError);
|
||||||
|
socket.on("data", (data: unknown) => this.receiveData(String(data)));
|
||||||
|
socket.on("error", (error: unknown) => {
|
||||||
|
console.error("MPV socket error:", error);
|
||||||
|
});
|
||||||
|
resolve(socket);
|
||||||
|
this.registerMpvObservers().catch((error: unknown) => {
|
||||||
|
console.error("Failed to register mpv observers:", error);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.once("error", onError);
|
||||||
|
socket.connect(path);
|
||||||
|
};
|
||||||
|
|
||||||
|
attemptConnection(1);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async registerMpvObservers() {
|
||||||
|
await this.writeCommand("observe_property", [ObservedProperty.MediaTitle, "media-title"]);
|
||||||
|
await this.writeCommand("observe_property", [ObservedProperty.Metadata, "metadata"]);
|
||||||
}
|
}
|
||||||
|
|
||||||
private handleEvent(event: string, data: any) {
|
private handleEvent(event: string, data: any) {
|
||||||
@@ -470,6 +548,29 @@ export class MediaPlayer {
|
|||||||
this.pendingCommands.delete(response.request_id);
|
this.pendingCommands.delete(response.request_id);
|
||||||
}
|
}
|
||||||
} else if (response.event) {
|
} else if (response.event) {
|
||||||
|
if (response.event === "start-file") {
|
||||||
|
// Clear any previous error for the file that is starting
|
||||||
|
const file = response.file || this.lastLoadCandidate;
|
||||||
|
if (file) {
|
||||||
|
this.currentFile = file;
|
||||||
|
this.playbackErrors.delete(file);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.currentMediaTitle = null;
|
||||||
|
this.currentPlaybackMetadata = {};
|
||||||
|
} else if (response.event === "property-change") {
|
||||||
|
this.handlePropertyChange(response);
|
||||||
|
} else if (response.event === "end-file" && response.reason === "error") {
|
||||||
|
const file = response.file || this.currentFile || this.lastLoadCandidate || "Unknown file";
|
||||||
|
const errorMessage = response.error || response["file-error"] || "Unknown playback error";
|
||||||
|
|
||||||
|
this.playbackErrors.set(file, errorMessage);
|
||||||
|
this.handleEvent(UserEvent.PlaybackError, {
|
||||||
|
filename: file,
|
||||||
|
error: errorMessage
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
this.handleEvent(UserEvent.MPDUpdate, response);
|
this.handleEvent(UserEvent.MPDUpdate, response);
|
||||||
} else {
|
} else {
|
||||||
console.log(response);
|
console.log(response);
|
||||||
@@ -480,4 +581,143 @@ export class MediaPlayer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private handlePropertyChange(response: any) {
|
||||||
|
const previousTitle = this.getCurrentDynamicTitle();
|
||||||
|
|
||||||
|
if (response.id === ObservedProperty.MediaTitle || response.name === "media-title") {
|
||||||
|
this.currentMediaTitle = typeof response.data === "string" ? response.data : null;
|
||||||
|
} else if (response.id === ObservedProperty.Metadata || response.name === "metadata") {
|
||||||
|
this.currentPlaybackMetadata = this.normalizeMpvMetadata(response.data);
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentTitle = this.getCurrentDynamicTitle();
|
||||||
|
if (currentTitle !== previousTitle) {
|
||||||
|
this.handleEvent(UserEvent.MetadataUpdate, {
|
||||||
|
url: this.currentFile,
|
||||||
|
title: currentTitle,
|
||||||
|
metadata: this.currentPlaybackMetadata
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private withCurrentDynamicTitle(item: PlaylistItem, dynamicTitle: string | null = this.getCurrentDynamicTitle(item)): PlaylistItem {
|
||||||
|
if (!dynamicTitle) {
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...item,
|
||||||
|
title: dynamicTitle,
|
||||||
|
metadata: {
|
||||||
|
...item.metadata,
|
||||||
|
title: dynamicTitle
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private getCurrentDynamicTitle(item?: PlaylistItem): string | null {
|
||||||
|
if (this.currentMediaTitle && !this.isFallbackMediaTitle(this.currentMediaTitle, item?.filename)) {
|
||||||
|
return this.currentMediaTitle.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.extractTitleFromMpvMetadata();
|
||||||
|
}
|
||||||
|
|
||||||
|
private extractTitleFromMpvMetadata(): string | null {
|
||||||
|
const titleKeys = new Set(["title", "icy-title", "icy_title", "streamtitle", "stream-title"]);
|
||||||
|
|
||||||
|
for (const key of Object.keys(this.currentPlaybackMetadata)) {
|
||||||
|
const normalizedKey = key.toLowerCase();
|
||||||
|
if (!titleKeys.has(normalizedKey)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const title = this.parseStreamTitle(this.currentPlaybackMetadata[key]);
|
||||||
|
if (title) {
|
||||||
|
return title;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseStreamTitle(value: string | undefined): string | null {
|
||||||
|
const trimmedValue = value?.trim();
|
||||||
|
if (!trimmedValue) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const streamTitleMatch = trimmedValue.match(/StreamTitle='([^']*)'/i);
|
||||||
|
return (streamTitleMatch?.[1] || trimmedValue).trim() || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizeMpvMetadata(data: unknown): Record<string, string> {
|
||||||
|
if (data === null || typeof data !== "object" || Array.isArray(data)) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
const metadata: Record<string, string> = {};
|
||||||
|
const rawMetadata = data as Record<string, unknown>;
|
||||||
|
|
||||||
|
for (const key of Object.keys(rawMetadata)) {
|
||||||
|
const value = rawMetadata[key];
|
||||||
|
if (value !== null && value !== undefined) {
|
||||||
|
metadata[key] = String(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return metadata;
|
||||||
|
}
|
||||||
|
|
||||||
|
private isFallbackMediaTitle(title: string, filename?: string): boolean {
|
||||||
|
const normalizedTitle = title.trim();
|
||||||
|
if (!normalizedTitle) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fallbackCandidates = new Set<string>();
|
||||||
|
if (filename) {
|
||||||
|
this.addFallbackTitleCandidates(fallbackCandidates, filename);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.currentFile) {
|
||||||
|
this.addFallbackTitleCandidates(fallbackCandidates, this.currentFile);
|
||||||
|
}
|
||||||
|
|
||||||
|
return fallbackCandidates.has(normalizedTitle);
|
||||||
|
}
|
||||||
|
|
||||||
|
private addFallbackTitleCandidates(candidates: Set<string>, value: string) {
|
||||||
|
candidates.add(value);
|
||||||
|
|
||||||
|
try {
|
||||||
|
candidates.add(decodeURIComponent(value));
|
||||||
|
} catch {
|
||||||
|
// Keep the original value if it is not URI-encoded.
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const url = new URL(value);
|
||||||
|
this.addPathTitleCandidates(candidates, url.pathname);
|
||||||
|
} catch {
|
||||||
|
this.addPathTitleCandidates(candidates, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private addPathTitleCandidates(candidates: Set<string>, path: string) {
|
||||||
|
const pathSegments = path.split("/").filter(Boolean);
|
||||||
|
const lastPathSegment = pathSegments[pathSegments.length - 1];
|
||||||
|
if (!lastPathSegment) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
candidates.add(lastPathSegment);
|
||||||
|
|
||||||
|
try {
|
||||||
|
candidates.add(decodeURIComponent(lastPathSegment));
|
||||||
|
} catch {
|
||||||
|
// Keep the original segment if it is not URI-encoded.
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,4 +29,5 @@ export interface PlaylistItem {
|
|||||||
playing?: boolean;
|
playing?: boolean;
|
||||||
current?: boolean;
|
current?: boolean;
|
||||||
metadata?: LinkMetadata;
|
metadata?: LinkMetadata;
|
||||||
|
playbackError?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
6
web/flake.lock
generated
6
web/flake.lock
generated
@@ -20,11 +20,11 @@
|
|||||||
},
|
},
|
||||||
"nixpkgs": {
|
"nixpkgs": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1762977756,
|
"lastModified": 1779560665,
|
||||||
"narHash": "sha256-4PqRErxfe+2toFJFgcRKZ0UI9NSIOJa+7RXVtBhy4KE=",
|
"narHash": "sha256-tpyBcxPpcQb8ukyNF7DoCwfSY3VPsxHoYwj00Cayv5o=",
|
||||||
"owner": "NixOS",
|
"owner": "NixOS",
|
||||||
"repo": "nixpkgs",
|
"repo": "nixpkgs",
|
||||||
"rev": "c5ae371f1a6a7fd27823bc500d9390b38c05fa55",
|
"rev": "64c08a7ca051951c8eae34e3e3cb1e202fe36786",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
|
|||||||
249
web/flake.nix
249
web/flake.nix
@@ -8,121 +8,7 @@
|
|||||||
|
|
||||||
outputs = { self, nixpkgs, flake-utils }:
|
outputs = { self, nixpkgs, flake-utils }:
|
||||||
let
|
let
|
||||||
# Define the NixOS module for the systemd service
|
mkQueuecube = pkgs: pkgs.buildNpmPackage {
|
||||||
nixosModule = { config, lib, pkgs, ... }:
|
|
||||||
let
|
|
||||||
cfg = config.services.queuecube;
|
|
||||||
in {
|
|
||||||
options.services.queuecube = {
|
|
||||||
enable = lib.mkEnableOption "QueueCube media player service";
|
|
||||||
|
|
||||||
port = lib.mkOption {
|
|
||||||
type = lib.types.port;
|
|
||||||
default = 3000;
|
|
||||||
description = "Port on which QueueCube will listen";
|
|
||||||
};
|
|
||||||
|
|
||||||
enable_video = lib.mkOption {
|
|
||||||
type = lib.types.bool;
|
|
||||||
default = false;
|
|
||||||
description = "Enable video playback";
|
|
||||||
};
|
|
||||||
|
|
||||||
store_path = lib.mkOption {
|
|
||||||
type = lib.types.str;
|
|
||||||
default = "/var/tmp/queuecube";
|
|
||||||
description = "Path to the store for QueueCube";
|
|
||||||
};
|
|
||||||
|
|
||||||
invidious = lib.mkOption {
|
|
||||||
type = lib.types.submodule {
|
|
||||||
options = {
|
|
||||||
enable = lib.mkOption {
|
|
||||||
type = lib.types.bool;
|
|
||||||
default = false;
|
|
||||||
description = "Enable Invidious";
|
|
||||||
};
|
|
||||||
|
|
||||||
url = lib.mkOption {
|
|
||||||
type = lib.types.str;
|
|
||||||
default = "http://invidious.nor";
|
|
||||||
description = "URL of the Invidious instance to use";
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
default = {
|
|
||||||
enable = false;
|
|
||||||
url = "http://invidious.nor";
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
user = lib.mkOption {
|
|
||||||
type = lib.types.str;
|
|
||||||
description = "User account under which QueueCube runs (required)";
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
config = lib.mkIf cfg.enable {
|
|
||||||
users.users.${cfg.user} = {
|
|
||||||
packages = [ self.packages.${pkgs.system}.queuecube ];
|
|
||||||
};
|
|
||||||
|
|
||||||
systemd.user.services.queuecube = {
|
|
||||||
description = "QueueCube media player service";
|
|
||||||
wantedBy = [ "default.target" ];
|
|
||||||
after = [ "pipewire.service" "pipewire-pulse.service" ];
|
|
||||||
|
|
||||||
serviceConfig = {
|
|
||||||
ExecStart = "${self.packages.${pkgs.system}.queuecube}/bin/queuecube";
|
|
||||||
Restart = "on-failure";
|
|
||||||
RestartSec = 5;
|
|
||||||
|
|
||||||
# Remove all resource limits for mpv to function properly
|
|
||||||
LimitNOFILE = "infinity"; # No limit on file descriptors
|
|
||||||
LimitMEMLOCK = "infinity"; # No limit on locked memory (for real-time audio)
|
|
||||||
LimitNPROC = "infinity"; # No limit on number of processes
|
|
||||||
LimitAS = "infinity"; # No limit on address space
|
|
||||||
LimitRSS = "infinity"; # No limit on resident set size
|
|
||||||
LimitCORE = "infinity"; # Allow core dumps for debugging
|
|
||||||
LimitDATA = "infinity"; # No limit on data segment
|
|
||||||
LimitSTACK = "infinity"; # No limit on stack size
|
|
||||||
LimitCPU = "infinity"; # No limit on CPU time
|
|
||||||
LimitRTPRIO = "99"; # Allow real-time priority
|
|
||||||
LimitRTTIME = "infinity"; # No limit on real-time scheduling
|
|
||||||
|
|
||||||
# Nice level for better performance
|
|
||||||
Nice = "-10";
|
|
||||||
|
|
||||||
# Allow access to necessary devices and features
|
|
||||||
PrivateDevices = false;
|
|
||||||
ProtectHome = false;
|
|
||||||
ProtectSystem = false;
|
|
||||||
NoNewPrivileges = false;
|
|
||||||
|
|
||||||
# Environment for X11 and runtime directories
|
|
||||||
Environment = [
|
|
||||||
"DISPLAY=:0"
|
|
||||||
];
|
|
||||||
};
|
|
||||||
|
|
||||||
environment = {
|
|
||||||
PORT = toString cfg.port;
|
|
||||||
ENABLE_VIDEO = if cfg.enable_video then "1" else "0";
|
|
||||||
USE_INVIDIOUS = if cfg.invidious.enable then "1" else "0";
|
|
||||||
INVIDIOUS_BASE_URL = cfg.invidious.url;
|
|
||||||
STORE_PATH = cfg.store_path;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
|
||||||
in
|
|
||||||
flake-utils.lib.eachDefaultSystem (system:
|
|
||||||
let
|
|
||||||
pkgs = nixpkgs.legacyPackages.${system};
|
|
||||||
|
|
||||||
# Define the package using buildNpmPackage
|
|
||||||
queuecube = pkgs.buildNpmPackage {
|
|
||||||
pname = "queuecube";
|
pname = "queuecube";
|
||||||
version = "0.1.0";
|
version = "0.1.0";
|
||||||
|
|
||||||
@@ -187,6 +73,136 @@
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
# Define the NixOS module for the systemd service
|
||||||
|
nixosModule = { config, lib, pkgs, ... }:
|
||||||
|
let
|
||||||
|
cfg = config.services.queuecube;
|
||||||
|
package = mkQueuecube pkgs;
|
||||||
|
in {
|
||||||
|
options.services.queuecube = {
|
||||||
|
enable = lib.mkEnableOption "QueueCube media player service";
|
||||||
|
|
||||||
|
port = lib.mkOption {
|
||||||
|
type = lib.types.port;
|
||||||
|
default = 3000;
|
||||||
|
description = "Port on which QueueCube will listen";
|
||||||
|
};
|
||||||
|
|
||||||
|
enable_video = lib.mkOption {
|
||||||
|
type = lib.types.bool;
|
||||||
|
default = false;
|
||||||
|
description = "Enable video playback";
|
||||||
|
};
|
||||||
|
|
||||||
|
enable_screenshare = lib.mkOption {
|
||||||
|
type = lib.types.bool;
|
||||||
|
default = false;
|
||||||
|
description = "Enable screensharing";
|
||||||
|
};
|
||||||
|
|
||||||
|
mpv_ytdl_format = lib.mkOption {
|
||||||
|
type = lib.types.nullOr lib.types.str;
|
||||||
|
default = "best";
|
||||||
|
description = "yt-dlp format selector passed to mpv. Set to null to use mpv's default format selection.";
|
||||||
|
};
|
||||||
|
|
||||||
|
store_path = lib.mkOption {
|
||||||
|
type = lib.types.str;
|
||||||
|
default = "/var/tmp/queuecube";
|
||||||
|
description = "Path to the store for QueueCube";
|
||||||
|
};
|
||||||
|
|
||||||
|
invidious = lib.mkOption {
|
||||||
|
type = lib.types.submodule {
|
||||||
|
options = {
|
||||||
|
enable = lib.mkOption {
|
||||||
|
type = lib.types.bool;
|
||||||
|
default = false;
|
||||||
|
description = "Enable Invidious";
|
||||||
|
};
|
||||||
|
|
||||||
|
url = lib.mkOption {
|
||||||
|
type = lib.types.str;
|
||||||
|
default = "http://invidious.nor";
|
||||||
|
description = "URL of the Invidious instance to use";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
default = {
|
||||||
|
enable = false;
|
||||||
|
url = "http://invidious.nor";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
user = lib.mkOption {
|
||||||
|
type = lib.types.str;
|
||||||
|
description = "User account under which QueueCube runs (required)";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
config = lib.mkIf cfg.enable {
|
||||||
|
users.users.${cfg.user} = {
|
||||||
|
packages = [ package ];
|
||||||
|
};
|
||||||
|
|
||||||
|
systemd.user.services.queuecube = {
|
||||||
|
description = "QueueCube media player service";
|
||||||
|
wantedBy = [ "default.target" ];
|
||||||
|
after = [ "pipewire.service" "pipewire-pulse.service" ];
|
||||||
|
|
||||||
|
serviceConfig = {
|
||||||
|
ExecStart = "${package}/bin/queuecube";
|
||||||
|
Restart = "on-failure";
|
||||||
|
RestartSec = 5;
|
||||||
|
|
||||||
|
# Remove all resource limits for mpv to function properly
|
||||||
|
LimitNOFILE = "infinity"; # No limit on file descriptors
|
||||||
|
LimitMEMLOCK = "infinity"; # No limit on locked memory (for real-time audio)
|
||||||
|
LimitNPROC = "infinity"; # No limit on number of processes
|
||||||
|
LimitAS = "infinity"; # No limit on address space
|
||||||
|
LimitRSS = "infinity"; # No limit on resident set size
|
||||||
|
LimitCORE = "infinity"; # Allow core dumps for debugging
|
||||||
|
LimitDATA = "infinity"; # No limit on data segment
|
||||||
|
LimitSTACK = "infinity"; # No limit on stack size
|
||||||
|
LimitCPU = "infinity"; # No limit on CPU time
|
||||||
|
LimitRTPRIO = "99"; # Allow real-time priority
|
||||||
|
LimitRTTIME = "infinity"; # No limit on real-time scheduling
|
||||||
|
|
||||||
|
# Nice level for better performance
|
||||||
|
Nice = "-10";
|
||||||
|
|
||||||
|
# Allow access to necessary devices and features
|
||||||
|
PrivateDevices = false;
|
||||||
|
ProtectHome = false;
|
||||||
|
ProtectSystem = false;
|
||||||
|
NoNewPrivileges = false;
|
||||||
|
|
||||||
|
# Environment for X11 and runtime directories
|
||||||
|
Environment = [
|
||||||
|
"DISPLAY=:0"
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
|
environment = {
|
||||||
|
PORT = toString cfg.port;
|
||||||
|
ENABLE_VIDEO = if cfg.enable_video then "1" else "0";
|
||||||
|
ENABLE_SCREENSHARE = if cfg.enable_screenshare then "1" else "0";
|
||||||
|
USE_INVIDIOUS = if cfg.invidious.enable then "1" else "0";
|
||||||
|
INVIDIOUS_BASE_URL = cfg.invidious.url;
|
||||||
|
STORE_PATH = cfg.store_path;
|
||||||
|
} // lib.optionalAttrs (cfg.mpv_ytdl_format != null) {
|
||||||
|
MPV_YTDL_FORMAT = cfg.mpv_ytdl_format;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
in
|
||||||
|
flake-utils.lib.eachDefaultSystem (system:
|
||||||
|
let
|
||||||
|
pkgs = nixpkgs.legacyPackages.${system};
|
||||||
|
queuecube = mkQueuecube pkgs;
|
||||||
|
|
||||||
in {
|
in {
|
||||||
packages = {
|
packages = {
|
||||||
default = queuecube;
|
default = queuecube;
|
||||||
@@ -201,8 +217,7 @@
|
|||||||
# Development environment
|
# Development environment
|
||||||
devShells.default = pkgs.mkShell {
|
devShells.default = pkgs.mkShell {
|
||||||
buildInputs = with pkgs; [
|
buildInputs = with pkgs; [
|
||||||
nodejs_20
|
nodejs
|
||||||
nodePackages.npm
|
|
||||||
mpv
|
mpv
|
||||||
yt-dlp
|
yt-dlp
|
||||||
pulseaudio
|
pulseaudio
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ export interface PlaylistItem {
|
|||||||
id: number;
|
id: number;
|
||||||
playing: boolean | null;
|
playing: boolean | null;
|
||||||
metadata?: Metadata;
|
metadata?: Metadata;
|
||||||
|
playbackError?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getDisplayTitle = (item: PlaylistItem): string => {
|
export const getDisplayTitle = (item: PlaylistItem): string => {
|
||||||
@@ -62,6 +63,7 @@ export enum ServerEvent {
|
|||||||
FavoritesUpdate = "favorites_update",
|
FavoritesUpdate = "favorites_update",
|
||||||
MetadataUpdate = "metadata_update",
|
MetadataUpdate = "metadata_update",
|
||||||
MPDUpdate = "mpd_update",
|
MPDUpdate = "mpd_update",
|
||||||
|
PlaybackError = "playback_error",
|
||||||
ScreenShare = "screen_share",
|
ScreenShare = "screen_share",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -199,6 +199,7 @@ const App: React.FC = () => {
|
|||||||
case ServerEvent.NowPlayingUpdate:
|
case ServerEvent.NowPlayingUpdate:
|
||||||
case ServerEvent.MetadataUpdate:
|
case ServerEvent.MetadataUpdate:
|
||||||
case ServerEvent.MPDUpdate:
|
case ServerEvent.MPDUpdate:
|
||||||
|
case ServerEvent.PlaybackError:
|
||||||
fetchPlaylist();
|
fetchPlaylist();
|
||||||
fetchNowPlaying();
|
fetchNowPlaying();
|
||||||
break;
|
break;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import React, { useState, useRef, useEffect, ReactNode } from 'react';
|
import React, { useState, useRef, useEffect, ReactNode } from 'react';
|
||||||
import { FaPlay, FaVolumeUp, FaVolumeOff } from 'react-icons/fa';
|
import { FaPlay, FaVolumeUp, FaVolumeOff, FaExclamationTriangle } from 'react-icons/fa';
|
||||||
import { getDisplayTitle, PlaylistItem } from '../api/player';
|
import { getDisplayTitle, PlaylistItem } from '../api/player';
|
||||||
|
|
||||||
export enum PlayState {
|
export enum PlayState {
|
||||||
@@ -19,6 +19,7 @@ export interface SongRowProps {
|
|||||||
|
|
||||||
const SongRow: React.FC<SongRowProps> = ({ song, auxControl, playState, onDelete, onPlay }) => {
|
const SongRow: React.FC<SongRowProps> = ({ song, auxControl, playState, onDelete, onPlay }) => {
|
||||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||||
|
const [showErrorDetails, setShowErrorDetails] = useState(false);
|
||||||
const buttonRef = useRef<HTMLButtonElement>(null);
|
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -38,6 +39,7 @@ const SongRow: React.FC<SongRowProps> = ({ song, auxControl, playState, onDelete
|
|||||||
}, [showDeleteConfirm]);
|
}, [showDeleteConfirm]);
|
||||||
|
|
||||||
const displayTitle = getDisplayTitle(song);
|
const displayTitle = getDisplayTitle(song);
|
||||||
|
const hasError = !!song.playbackError;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={classNames(
|
<div className={classNames(
|
||||||
@@ -46,16 +48,46 @@ const SongRow: React.FC<SongRowProps> = ({ song, auxControl, playState, onDelete
|
|||||||
"bg-black/30": playState === PlayState.NotPlaying,
|
"bg-black/30": playState === PlayState.NotPlaying,
|
||||||
})}>
|
})}>
|
||||||
<div className="flex flex-row gap-2">
|
<div className="flex flex-row gap-2">
|
||||||
|
<div className="relative">
|
||||||
<button
|
<button
|
||||||
className="text-white/40 hover:text-white transition-colors px-3 py-1 rounded"
|
className={classNames(
|
||||||
onClick={onPlay}
|
"transition-colors px-3 py-1 rounded",
|
||||||
|
hasError ? "text-amber-300 hover:text-amber-100" : "text-white/40 hover:text-white"
|
||||||
|
)}
|
||||||
|
onClick={() => {
|
||||||
|
if (hasError) {
|
||||||
|
setShowErrorDetails((prev) => !prev);
|
||||||
|
} else {
|
||||||
|
onPlay();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onMouseEnter={() => {
|
||||||
|
if (hasError) {
|
||||||
|
setShowErrorDetails(true);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onMouseLeave={() => {
|
||||||
|
if (hasError) {
|
||||||
|
setShowErrorDetails(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
title={hasError ? song.playbackError : undefined}
|
||||||
>
|
>
|
||||||
{
|
{
|
||||||
|
hasError ? <FaExclamationTriangle size={12} /> :
|
||||||
playState === PlayState.Playing ? <FaVolumeUp size={12} />
|
playState === PlayState.Playing ? <FaVolumeUp size={12} />
|
||||||
: playState === PlayState.Paused ? <FaVolumeOff size={12} />
|
: playState === PlayState.Paused ? <FaVolumeOff size={12} />
|
||||||
: <FaPlay size={12} />
|
: <FaPlay size={12} />
|
||||||
}
|
}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
{hasError && showErrorDetails && (
|
||||||
|
<div className="absolute z-10 top-full left-0 mt-1 w-64 p-2 text-xs text-white bg-red-600/90 rounded shadow-lg">
|
||||||
|
<div className="font-semibold mb-1">Playback error</div>
|
||||||
|
<div className="break-words">{song.playbackError}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-grow min-w-0">
|
<div className="flex-grow min-w-0">
|
||||||
|
|||||||
Reference in New Issue
Block a user