25 Commits

Author SHA1 Message Date
1c9916c69d Regenerate Fastlane docs 2026-05-30 20:47:17 -07:00
cfd023be69 Let Xcode manage release signing 2026-05-30 20:45:52 -07:00
719359b940 Add Fastlane TestFlight release lane 2026-05-30 20:44:45 -07:00
f4d0bd7ca0 pin yt-dlp version 2026-05-28 23:00:10 -07:00
26a13c27d6 update flake.lock 2026-05-28 22:05:06 -07:00
cfe5063828 backend: observe metadata/title changes 2026-05-01 08:51:38 -07:00
69742ce2d9 build bump to 13 2026-02-20 00:08:46 -08:00
5b3787f19f retry logic for when mpv doesnt start right away 2026-02-18 17:57:42 -08:00
9b0f6e2123 Nix: add screen share option 2026-01-31 22:34:55 -08:00
2d4eae9676 version bump, 1.5.2 2025-11-15 18:25:44 -08:00
848c4c2b55 ios: minheight on mediaitemcell 2025-11-15 18:25:01 -08:00
a6ca763730 version bump and warning fix 2025-11-15 18:20:37 -08:00
0916de60f3 error alert cleanup 2025-11-15 18:20:03 -08:00
cfc6e6c411 ios: add error handling 2025-11-15 18:16:03 -08:00
bc54735d1f web: add error surfacing 2025-11-15 17:58:29 -08:00
04d23bec1e ios: bump version 2025-11-15 17:47:13 -08:00
3aa819eccc ios: Fixes for add media view
- Adds paste button
- Fix autofocus behavior (using UIKit)
- Remove pointless sheet detents
2025-11-15 17:42:00 -08:00
718518c3f2 ios: tighten fonts / list styles 2025-11-15 17:13:11 -08:00
f3053c1db1 ios: fix backgrounding error 2025-11-15 16:15:53 -08:00
58a43a617e MediaPlayer: remove config line for reconnect rules 2025-11-15 15:45:34 -08:00
85fd112a1a web: update nix flake lock 2025-11-15 15:35:55 -08:00
8ac494049d dont mess with these 2025-11-02 11:40:14 -08:00
4e3bcb406a flake: try and remove restrictions to improve mpv reliability 2025-11-02 11:35:11 -08:00
ec1ee508b3 backend: better timeouts/stream reconnect options for mpv 2025-11-02 10:52:58 -08:00
4e8cd11d8f fix screenshots 2025-10-10 23:15:44 -07:00
22 changed files with 813 additions and 193 deletions

6
.gitignore vendored Normal file
View File

@@ -0,0 +1,6 @@
/.env
ios/build/
fastlane/report.xml
fastlane/Preview.html
fastlane/screenshots/
fastlane/test_output/

6
fastlane/Appfile Normal file
View 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
View 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
View 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).

View File

@@ -14,6 +14,7 @@
CD8ACBBF2DC5B8F2008BF856 /* Exceptions for "QueueCube" folder in "QueueCube" target */ = { CD8ACBBF2DC5B8F2008BF856 /* Exceptions for "QueueCube" folder in "QueueCube" target */ = {
isa = PBXFileSystemSynchronizedBuildFileExceptionSet; isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
membershipExceptions = ( membershipExceptions = (
App/Entitlements.plist,
App/Info.plist, App/Info.plist,
); );
target = CD4E9B962D7691C20066FC17 /* QueueCube */; target = CD4E9B962D7691C20066FC17 /* QueueCube */;
@@ -268,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 = 5; CURRENT_PROJECT_VERSION = 13;
DEVELOPMENT_TEAM = DQQH5H6GBD; DEVELOPMENT_TEAM = DQQH5H6GBD;
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
@@ -283,7 +284,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = 1.2; 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";
@@ -304,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 = 5; CURRENT_PROJECT_VERSION = 13;
DEVELOPMENT_TEAM = DQQH5H6GBD; DEVELOPMENT_TEAM = DQQH5H6GBD;
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
@@ -319,7 +320,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = 1.2; 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";

View File

@@ -12,13 +12,23 @@ struct MediaItem: Codable
let filename: String? let filename: String?
let title: String? let title: String?
let id: Int let id: Int
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 ?? filename ?? "item \(id)" metadata?.title ?? title ?? displayFilename ?? "item \(id)"
}
private var displayFilename: String? {
guard let filename else { return nil }
if let url = URL(string: filename) {
return url.lastPathComponent
}
return filename
} }
// MARK: - Types // MARK: - Types
@@ -234,8 +244,6 @@ actor API
private static func notifyError(_ error: any Swift.Error, continuation: AsyncStream<StreamEvent>.Continuation) { private static func notifyError(_ error: any Swift.Error, continuation: AsyncStream<StreamEvent>.Continuation) {
print("Websocket Error: \(error)") print("Websocket Error: \(error)")
let nsError = error as NSError
// Always notify observers of WebSocket errors so reconnection can happen // Always notify observers of WebSocket errors so reconnection can happen
// The UI layer can decide whether to show the error to the user // The UI layer can decide whether to show the error to the user
continuation.yield(.error(.websocketError(error))) continuation.yield(.error(.websocketError(error)))
@@ -273,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

View File

@@ -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" : {

View File

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

View File

@@ -10,17 +10,23 @@ import SwiftUI
struct AddMediaView: View struct AddMediaView: View
{ {
@Binding var model: ViewModel @Binding var model: ViewModel
@FocusState var fieldFocused: Bool
var body: some View { var body: some View {
NavigationStack { NavigationStack {
Form { Form {
// Add URL // Add URL
Section { Section {
TextField(.addAnyURL, text: $model.fieldContents) HStack {
.autocapitalization(.none) AutofocusingTextField(String(localized: "ADD_ANY_URL"), text: $model.fieldContents)
.autocorrectionDisabled() .autocapitalization(.none)
.focused($fieldFocused) .autocorrectionDisabled()
PasteButton(payloadType: String.self) { payload in
guard let contents = payload.first else { return }
model.fieldContents = contents
}
.labelStyle(.iconOnly)
}
} }
if model.supportsSearch { if model.supportsSearch {
@@ -35,8 +41,6 @@ struct AddMediaView: View
} }
} }
} }
.task { fieldFocused = true }
.onAppear { model.activeDetent = ViewModel.Detent.collapsed.value }
.navigationTitle(.addMedia) .navigationTitle(.addMedia)
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
.toolbar { .toolbar {
@@ -72,21 +76,6 @@ struct AddMediaView: View
var onSearch: () -> Void = { } var onSearch: () -> Void = { }
var supportsSearch: Bool = true var supportsSearch: Bool = true
var activeDetent: PresentationDetent = Detent.collapsed.value
enum Detent: CaseIterable
{
case collapsed
case expanded
var value: PresentationDetent {
switch self {
case .collapsed: .height(320.0)
case .expanded: .large
}
}
}
fileprivate func addButtonTapped() { fileprivate func addButtonTapped() {
onAdd(fieldContents) onAdd(fieldContents)
} }
@@ -107,11 +96,8 @@ struct SearchMediaView: View
Image(systemName: "magnifyingglass") Image(systemName: "magnifyingglass")
.foregroundColor(.secondary) .foregroundColor(.secondary)
TextField(.searchForMedia, text: $searchText) AutofocusingTextField(String(localized: "SEARCH_FOR_MEDIA"), text: $searchText, onSubmit: performSearch)
.focused($searchFieldFocused) .focused($searchFieldFocused)
.onSubmit {
performSearch()
}
if !searchText.isEmpty { if !searchText.isEmpty {
Button { Button {
@@ -153,7 +139,6 @@ struct SearchMediaView: View
.navigationTitle(.searchForMedia) .navigationTitle(.searchForMedia)
.presentationBackground(.regularMaterial) .presentationBackground(.regularMaterial)
.onAppear { .onAppear {
model.activeDetent = AddMediaView.ViewModel.Detent.expanded.value
searchFieldFocused = true searchFieldFocused = true
} }
} }

View File

@@ -0,0 +1,71 @@
//
// AutofocusingTextField.swift
// QueueCube
//
// Created by James Magahern on 11/15/25.
//
import SwiftUI
import UIKit
/// Stupid: it appears to be impossible to make it so SwiftUI's `.focused(_:)` modifier takes place during the
/// presentation of a sheet, so this needs to exist just to make sure it's made first responder as soon as it moves to
/// the view hierarchy.
struct AutofocusingTextField: UIViewRepresentable
{
let placeholder: String
@Binding var text: String
var onSubmit: () -> Void = {}
init(_ placeholder: String, text: Binding<String>, onSubmit: @escaping () -> Void = {}) {
self.placeholder = placeholder
self._text = text
self.onSubmit = onSubmit
}
func makeUIView(context: Context) -> UITextField {
let tf = FirstResponderTextField()
tf.placeholder = placeholder
tf.delegate = context.coordinator
tf.returnKeyType = .done
tf.setContentHuggingPriority(.defaultHigh, for: .vertical)
tf.autocorrectionType = .no
tf.autocapitalizationType = .none
return tf
}
func updateUIView(_ uiView: UITextField, context: Context) {
if uiView.text != text {
uiView.text = text
}
context.coordinator.parent = self // keep latest onSubmit/text binding
}
func makeCoordinator() -> Coordinator {
Coordinator(parent: self)
}
final class Coordinator: NSObject, UITextFieldDelegate {
var parent: AutofocusingTextField
init(parent: AutofocusingTextField) {
self.parent = parent
}
func textFieldDidChangeSelection(_ textField: UITextField) {
parent.text = textField.text ?? ""
}
func textFieldShouldReturn(_ textField: UITextField) -> Bool {
parent.onSubmit()
return true
}
}
}
final class FirstResponderTextField: UITextField {
override func didMoveToSuperview() {
super.didMoveToSuperview()
becomeFirstResponder()
}
}

View File

@@ -29,10 +29,6 @@ struct ContentView: View
.sheet(isPresented: $model.isAddMediaSheetPresented) { .sheet(isPresented: $model.isAddMediaSheetPresented) {
AddMediaView(model: $model.addMediaViewModel) AddMediaView(model: $model.addMediaViewModel)
.presentationBackground(.regularMaterial) .presentationBackground(.regularMaterial)
.presentationDetents(
Set(AddMediaView.ViewModel.Detent.allCases.map { $0.value }),
selection: $model.addMediaViewModel.activeDetent
)
} }
.sheet(isPresented: $model.isEditSheetPresented) { .sheet(isPresented: $model.isEditSheetPresented) {
EditItemView(model: $model.editMediaViewModel) EditItemView(model: $model.editMediaViewModel)
@@ -91,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
) )
} }
} }
@@ -104,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
) )
} }
} }
@@ -121,9 +119,15 @@ extension ContentView
await clearConnectionErrorIfNecessary() await clearConnectionErrorIfNecessary()
await handle(event: event) await handle(event: event)
case .error(let error): case .error(let error):
// Ignore if we're in the bg
guard scenePhase == .active else { break }
// Check if this is a backgrounding error (connection abort) // Check if this is a backgrounding error (connection abort)
let nsError = error as NSError var isBackgroundingError = false
let isBackgroundingError = nsError.code == 53 if case let .websocketError(wsError) = error {
let nsError = wsError as NSError
isBackgroundingError = nsError.code == 53
}
// Only show connection error to user if it's not a backgrounding error // Only show connection error to user if it's not a backgrounding error
if !isBackgroundingError { if !isBackgroundingError {
@@ -158,9 +162,10 @@ 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:
// This means we're online. // This means we're online.
await clearConnectionErrorIfNecessary() await clearConnectionErrorIfNecessary()

View File

@@ -325,18 +325,9 @@ struct ServerSelectionToolbarModifier: ViewModifier
} }
} }
} }
#if false
// TODO
Section {
Button(.addServer) {
}
}
#endif
} label: { } label: {
Label(model.selectedServer?.displayName ?? "Servers", systemImage: "chevron.down") Label(model.selectedServer?.displayName ?? "Servers", systemImage: "chevron.down")
.labelStyle(.titleAndIcon) .labelStyle(.titleOnly)
} }
.buttonBorderShape(.capsule) .buttonBorderShape(.capsule)
.buttonStyle(.bordered) .buttonStyle(.bordered)

View File

@@ -41,16 +41,16 @@ struct NowPlayingView: View
VStack { VStack {
if let title = model.title { if let title = model.title {
Text(title) Text(title)
.font(.title2) .font(.title3)
.lineLimit(1) .lineLimit(1)
.bold() .bold()
} }
if let subtitle = model.subtitle { if let subtitle = model.subtitle {
Text(subtitle) Text(subtitle)
.font(.title3) .font(.subheadline)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
.lineLimit(1) .lineLimit(2)
} }
if nothingQueued { if nothingQueued {

View File

@@ -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,7 +57,9 @@ 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 {
if model.items.isEmpty { if model.items.isEmpty {
@@ -68,22 +73,31 @@ struct MediaListView: View
List($model.items, editActions: .delete) { item in List($model.items, editActions: .delete) { item in
let item = item.wrappedValue let item = item.wrappedValue
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 {
switch model.mode { if let _ = item.playbackError {
case .playlist: errorAlertItem = item
model.onSeek(item) isShowingErrorAlert = true
case .favorites: } else {
model.onPlay(item) switch model.mode {
case .playlist:
model.onSeek(item)
case .favorites:
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,26 +162,24 @@ 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)
VStack(alignment: .leading) { VStack(alignment: .leading) {
Text(title) Text(title)
.bold()
.font(.subheadline)
.tint(.primary) .tint(.primary)
.lineLimit(1) .lineLimit(1)
Text(subtitle) Text(subtitle)
.font(.caption)
.foregroundColor(.secondary) .foregroundColor(.secondary)
.lineLimit(1) .lineLimit(1)
} }
@@ -161,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
@@ -171,4 +210,3 @@ struct MediaItemCell: View
case paused case paused
} }
} }

View File

Before

Width:  |  Height:  |  Size: 196 KiB

After

Width:  |  Height:  |  Size: 196 KiB

View File

@@ -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) => {
...item, const enhancedItem = {
metadata: this.metadata.get(item.filename) || {} ...item,
})); 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.
}
}
} }

View File

@@ -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
View File

@@ -20,11 +20,11 @@
}, },
"nixpkgs": { "nixpkgs": {
"locked": { "locked": {
"lastModified": 1740828860, "lastModified": 1779560665,
"narHash": "sha256-cjbHI+zUzK5CPsQZqMhE3npTyYFt9tJ3+ohcfaOF/WM=", "narHash": "sha256-tpyBcxPpcQb8ukyNF7DoCwfSY3VPsxHoYwj00Cayv5o=",
"owner": "NixOS", "owner": "NixOS",
"repo": "nixpkgs", "repo": "nixpkgs",
"rev": "303bd8071377433a2d8f76e684ec773d70c5b642", "rev": "64c08a7ca051951c8eae34e3e3cb1e202fe36786",
"type": "github" "type": "github"
}, },
"original": { "original": {

View File

@@ -8,10 +8,76 @@
outputs = { self, nixpkgs, flake-utils }: outputs = { self, nixpkgs, flake-utils }:
let let
mkQueuecube = pkgs: pkgs.buildNpmPackage {
pname = "queuecube";
version = "0.1.0";
src = ./.;
# Skip the standard buildPhase and provide our own
dontNpmBuild = true;
buildPhase = ''
# First install all dependencies
npm install
# Then run the build with workspaces flag
npm run build --workspaces
'';
# Runtime dependencies
buildInputs = with pkgs; [
mpv
yt-dlp
pulseaudio
];
# Create a wrapper script to ensure runtime deps are available
postInstall = ''
# Create the necessary directories
mkdir -p $out/lib/node_modules/queuecube
# Copy the entire project with built files
cp -r . $out/lib/node_modules/queuecube
# Install the frontend build to the backend dist directory
mkdir -p $out/lib/node_modules/queuecube/backend/dist/
cp -r frontend/dist $out/lib/node_modules/queuecube/backend/dist/frontend
# Create bin directory if it doesn't exist
mkdir -p $out/bin
# Create executable script
cat > $out/bin/queuecube <<EOF
#!/bin/sh
exec ${pkgs.nodejs}/bin/node $out/lib/node_modules/queuecube/backend/build/server.js
EOF
# Make it executable
chmod +x $out/bin/queuecube
# Wrap the program to include runtime deps in PATH
wrapProgram $out/bin/queuecube \
--prefix PATH : ${pkgs.lib.makeBinPath [
pkgs.mpv
pkgs.yt-dlp
pkgs.pulseaudio
]}
'';
# Let buildNpmPackage handle npm package hash
npmDepsHash = "sha256-kwbWqNqji0EcBeRuc/sqQUuGQkE+P8puLTfpAyRRzgY=";
meta = with pkgs.lib; {
description = "NodeJS application with media playback capabilities";
platforms = platforms.linux;
};
};
# Define the NixOS module for the systemd service # Define the NixOS module for the systemd service
nixosModule = { config, lib, pkgs, ... }: nixosModule = { config, lib, pkgs, ... }:
let let
cfg = config.services.queuecube; cfg = config.services.queuecube;
package = mkQueuecube pkgs;
in { in {
options.services.queuecube = { options.services.queuecube = {
enable = lib.mkEnableOption "QueueCube media player service"; enable = lib.mkEnableOption "QueueCube media player service";
@@ -27,6 +93,18 @@
default = false; default = false;
description = "Enable video playback"; 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 { store_path = lib.mkOption {
type = lib.types.str; type = lib.types.str;
@@ -65,29 +143,56 @@
config = lib.mkIf cfg.enable { config = lib.mkIf cfg.enable {
users.users.${cfg.user} = { users.users.${cfg.user} = {
packages = [ self.packages.${pkgs.system}.queuecube ]; packages = [ package ];
}; };
systemd.user.services.queuecube = { systemd.user.services.queuecube = {
description = "QueueCube media player service"; description = "QueueCube media player service";
wantedBy = [ "default.target" ]; wantedBy = [ "default.target" ];
after = [ "pipewire.service" "pipewire-pulse.service" ]; after = [ "pipewire.service" "pipewire-pulse.service" ];
serviceConfig = { serviceConfig = {
ExecStart = "${self.packages.${pkgs.system}.queuecube}/bin/queuecube"; ExecStart = "${package}/bin/queuecube";
Restart = "on-failure"; Restart = "on-failure";
RestartSec = 5; RestartSec = 5;
# Allow access to X11 for mpv # Remove all resource limits for mpv to function properly
Environment = [ "DISPLAY=:0" ]; 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 = { environment = {
PORT = toString cfg.port; PORT = toString cfg.port;
ENABLE_VIDEO = if cfg.enable_video then "1" else "0"; 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"; USE_INVIDIOUS = if cfg.invidious.enable then "1" else "0";
INVIDIOUS_BASE_URL = cfg.invidious.url; INVIDIOUS_BASE_URL = cfg.invidious.url;
STORE_PATH = cfg.store_path; STORE_PATH = cfg.store_path;
} // lib.optionalAttrs (cfg.mpv_ytdl_format != null) {
MPV_YTDL_FORMAT = cfg.mpv_ytdl_format;
}; };
}; };
}; };
@@ -96,72 +201,7 @@
flake-utils.lib.eachDefaultSystem (system: flake-utils.lib.eachDefaultSystem (system:
let let
pkgs = nixpkgs.legacyPackages.${system}; pkgs = nixpkgs.legacyPackages.${system};
queuecube = mkQueuecube pkgs;
# Define the package using buildNpmPackage
queuecube = pkgs.buildNpmPackage {
pname = "queuecube";
version = "0.1.0";
src = ./.;
# Skip the standard buildPhase and provide our own
dontNpmBuild = true;
buildPhase = ''
# First install all dependencies
npm install
# Then run the build with workspaces flag
npm run build --workspaces
'';
# Runtime dependencies
buildInputs = with pkgs; [
mpv
yt-dlp
pulseaudio
];
# Create a wrapper script to ensure runtime deps are available
postInstall = ''
# Create the necessary directories
mkdir -p $out/lib/node_modules/queuecube
# Copy the entire project with built files
cp -r . $out/lib/node_modules/queuecube
# Install the frontend build to the backend dist directory
mkdir -p $out/lib/node_modules/queuecube/backend/dist/
cp -r frontend/dist $out/lib/node_modules/queuecube/backend/dist/frontend
# Create bin directory if it doesn't exist
mkdir -p $out/bin
# Create executable script
cat > $out/bin/queuecube <<EOF
#!/bin/sh
exec ${pkgs.nodejs}/bin/node $out/lib/node_modules/queuecube/backend/build/server.js
EOF
# Make it executable
chmod +x $out/bin/queuecube
# Wrap the program to include runtime deps in PATH
wrapProgram $out/bin/queuecube \
--prefix PATH : ${pkgs.lib.makeBinPath [
pkgs.mpv
pkgs.yt-dlp
pkgs.pulseaudio
]}
'';
# Let buildNpmPackage handle npm package hash
npmDepsHash = "sha256-kwbWqNqji0EcBeRuc/sqQUuGQkE+P8puLTfpAyRRzgY=";
meta = with pkgs.lib; {
description = "NodeJS application with media playback capabilities";
platforms = platforms.linux;
};
};
in { in {
packages = { packages = {
@@ -177,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

View File

@@ -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",
} }

View File

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

View File

@@ -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">
<button <div className="relative">
className="text-white/40 hover:text-white transition-colors px-3 py-1 rounded" <button
onClick={onPlay} className={classNames(
> "transition-colors px-3 py-1 rounded",
{ hasError ? "text-amber-300 hover:text-amber-100" : "text-white/40 hover:text-white"
playState === PlayState.Playing ? <FaVolumeUp size={12} /> )}
: playState === PlayState.Paused ? <FaVolumeOff size={12} /> onClick={() => {
: <FaPlay size={12} /> if (hasError) {
} setShowErrorDetails((prev) => !prev);
</button> } 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.Paused ? <FaVolumeOff size={12} />
: <FaPlay size={12} />
}
</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">