7 Commits

Author SHA1 Message Date
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
12 changed files with 177 additions and 42 deletions

View File

@@ -269,7 +269,7 @@
CODE_SIGN_IDENTITY = "Apple Development";
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 5;
CURRENT_PROJECT_VERSION = 12;
DEVELOPMENT_TEAM = DQQH5H6GBD;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
@@ -284,7 +284,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.2;
MARKETING_VERSION = 1.5.2;
PRODUCT_BUNDLE_IDENTIFIER = net.buzzert.QueueCube;
PRODUCT_NAME = "$(TARGET_NAME)";
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
@@ -305,7 +305,7 @@
CODE_SIGN_IDENTITY = "Apple Development";
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 5;
CURRENT_PROJECT_VERSION = 12;
DEVELOPMENT_TEAM = DQQH5H6GBD;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
@@ -320,7 +320,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.2;
MARKETING_VERSION = 1.5.2;
PRODUCT_BUNDLE_IDENTIFIER = net.buzzert.QueueCube;
PRODUCT_NAME = "$(TARGET_NAME)";
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";

View File

@@ -16,6 +16,7 @@ struct MediaItem: Codable
let current: Bool?
let playing: Bool?
let metadata: Metadata?
let playbackError: String?
var displayTitle: String {
metadata?.title ?? title ?? displayFilename ?? "item \(id)"
@@ -280,6 +281,7 @@ actor API
case favoritesUpdate = "favorites_update"
case metadataUpdate = "metadata_update"
case mpdUpdate = "mpd_update"
case playbackError = "playback_error"
// Private UI events
case receivedWebsocketPong

View File

@@ -104,6 +104,16 @@
}
}
},
"DELETE" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Delete"
}
}
}
},
"DISCOVERED" : {
"localizations" : {
"en" : {
@@ -247,6 +257,16 @@
},
"Nothing here yet." : {
},
"PLAYBACK_ERROR" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Playback Error"
}
}
}
},
"PLAYLIST" : {
"localizations" : {
@@ -360,6 +380,16 @@
}
}
},
"Unknown error" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Unknown Error"
}
}
}
},
"URL" : {
"localizations" : {
"en" : {

View File

@@ -46,4 +46,6 @@ extension LocalizedStringKey
static let notPlaying = LocalizedStringKey("NOT_PLAYING")
static let url = LocalizedStringKey("URL")
static let title = LocalizedStringKey("TITLE")
static let playbackError = LocalizedStringKey("PLAYBACK_ERROR")
static let delete = LocalizedStringKey("DELETE")
}

View File

@@ -29,6 +29,8 @@ struct AutofocusingTextField: UIViewRepresentable
tf.delegate = context.coordinator
tf.returnKeyType = .done
tf.setContentHuggingPriority(.defaultHigh, for: .vertical)
tf.autocorrectionType = .no
tf.autocapitalizationType = .none
return tf
}

View File

@@ -87,7 +87,8 @@ extension ContentView
title: mediaItem.displayTitle,
filename: mediaItem.filename ?? "<null>",
index: idx,
isCurrent: mediaItem.current ?? false
isCurrent: mediaItem.current ?? false,
playbackError: mediaItem.playbackError
)
}
}
@@ -100,7 +101,8 @@ extension ContentView
id: String(mediaItem.id),
title: mediaItem.displayTitle,
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 .metadataUpdate: fallthrough
case .mpdUpdate:
case .mpdUpdate: fallthrough
case .playbackError:
await refresh([.playlist, .nowPlaying, .favorites])
case .receivedWebsocketPong:

View File

@@ -14,17 +14,19 @@ struct MediaListItem: Identifiable
let filename: String
let index: Int?
let isCurrent: Bool
let playbackError: String?
var id: String {
_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.title = title
self.filename = filename
self.index = index
self.isCurrent = isCurrent
self.playbackError = playbackError
}
}
@@ -45,6 +47,7 @@ class MediaListViewModel
var onQueue: (MediaListItem) -> Void = { _ in }
var onEdit: (MediaListItem) -> Void = { _ in }
var onFavorite: (MediaListItem) -> Void = { _ in }
var onDelete: (MediaListItem) -> Void = { _ in }
init(mode: MediaListMode) {
self.mode = mode
@@ -54,6 +57,8 @@ class MediaListViewModel
struct MediaListView: View
{
@Binding var model: MediaListViewModel
@State private var errorAlertItem: MediaListItem? = nil
@State private var isShowingErrorAlert: Bool = false
var body: some View {
VStack {
@@ -70,20 +75,29 @@ struct MediaListView: View
let state = item.isCurrent ? (model.isPlaying ? MediaItemCell.State.playing : MediaItemCell.State.paused) : .queued
Button {
if let _ = item.playbackError {
errorAlertItem = item
isShowingErrorAlert = true
} else {
switch model.mode {
case .playlist:
model.onSeek(item)
case .favorites:
model.onPlay(item)
}
}
} label: {
MediaItemCell(
title: item.title,
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 {
Button(.copyTitle) {
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 subtitle: String
let state: State
let playbackError: String?
var body: some View {
let icon: String = switch state {
case .queued: "play.fill"
case .playing: "speaker.wave.3.fill"
case .paused: "speaker.fill"
}
HStack {
Image(systemName: icon)
.tint(Color.primary)
Image(systemName: iconName)
.tint(playbackError == nil ? Color.primary : Color.orange)
.frame(width: 15.0)
.padding(.trailing, 10.0)
@@ -164,6 +187,19 @@ struct MediaItemCell: View
Spacer()
}
.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
@@ -174,4 +210,3 @@ struct MediaItemCell: View
case paused
}
}

View File

@@ -37,6 +37,7 @@ enum UserEvent {
FavoritesUpdate = "favorites_update",
MetadataUpdate = "metadata_update",
MPDUpdate = "mpd_update",
PlaybackError = "playback_error",
}
export interface Features {
@@ -57,6 +58,9 @@ export class MediaPlayer {
private dataBuffer: string = '';
private metadata: Map<string, LinkMetadata> = new Map();
private bonjourInstance: Bonjour | null = null;
private playbackErrors: Map<string, string> = new Map();
private currentFile: string | null = null;
private lastLoadCandidate: string | null = null;
constructor() {
this.socket = this.tryRespawnPlayerProcess();
@@ -152,7 +156,8 @@ export class MediaPlayer {
const playlist = response.data as PlaylistItem[];
return playlist.map((item: PlaylistItem) => ({
...item,
metadata: this.metadata.get(item.filename) || {}
metadata: this.metadata.get(item.filename) || {},
playbackError: this.playbackErrors.get(item.filename)
}));
});
}
@@ -352,6 +357,8 @@ export class MediaPlayer {
}
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(',')]));
if (fetchMetadata) {
@@ -470,6 +477,24 @@ export class MediaPlayer {
this.pendingCommands.delete(response.request_id);
}
} 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);
}
} 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);
} else {
console.log(response);

View File

@@ -29,4 +29,5 @@ export interface PlaylistItem {
playing?: boolean;
current?: boolean;
metadata?: LinkMetadata;
playbackError?: string;
}

View File

@@ -28,6 +28,7 @@ export interface PlaylistItem {
id: number;
playing: boolean | null;
metadata?: Metadata;
playbackError?: string;
}
export const getDisplayTitle = (item: PlaylistItem): string => {
@@ -62,6 +63,7 @@ export enum ServerEvent {
FavoritesUpdate = "favorites_update",
MetadataUpdate = "metadata_update",
MPDUpdate = "mpd_update",
PlaybackError = "playback_error",
ScreenShare = "screen_share",
}

View File

@@ -199,6 +199,7 @@ const App: React.FC = () => {
case ServerEvent.NowPlayingUpdate:
case ServerEvent.MetadataUpdate:
case ServerEvent.MPDUpdate:
case ServerEvent.PlaybackError:
fetchPlaylist();
fetchNowPlaying();
break;

View File

@@ -1,6 +1,6 @@
import classNames from 'classnames';
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';
export enum PlayState {
@@ -19,6 +19,7 @@ export interface SongRowProps {
const SongRow: React.FC<SongRowProps> = ({ song, auxControl, playState, onDelete, onPlay }) => {
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [showErrorDetails, setShowErrorDetails] = useState(false);
const buttonRef = useRef<HTMLButtonElement>(null);
useEffect(() => {
@@ -38,6 +39,7 @@ const SongRow: React.FC<SongRowProps> = ({ song, auxControl, playState, onDelete
}, [showDeleteConfirm]);
const displayTitle = getDisplayTitle(song);
const hasError = !!song.playbackError;
return (
<div className={classNames(
@@ -46,16 +48,46 @@ const SongRow: React.FC<SongRowProps> = ({ song, auxControl, playState, onDelete
"bg-black/30": playState === PlayState.NotPlaying,
})}>
<div className="flex flex-row gap-2">
<div className="relative">
<button
className="text-white/40 hover:text-white transition-colors px-3 py-1 rounded"
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"
)}
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.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 className="flex-grow min-w-0">