web: add error surfacing
This commit is contained in:
@@ -37,6 +37,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 +58,9 @@ 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;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.socket = this.tryRespawnPlayerProcess();
|
this.socket = this.tryRespawnPlayerProcess();
|
||||||
@@ -152,7 +156,8 @@ export class MediaPlayer {
|
|||||||
const playlist = response.data as PlaylistItem[];
|
const playlist = response.data as PlaylistItem[];
|
||||||
return playlist.map((item: PlaylistItem) => ({
|
return playlist.map((item: PlaylistItem) => ({
|
||||||
...item,
|
...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[] = []) {
|
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) {
|
||||||
@@ -470,6 +477,24 @@ 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);
|
||||||
|
}
|
||||||
|
} 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);
|
||||||
|
|||||||
@@ -29,4 +29,5 @@ export interface PlaylistItem {
|
|||||||
playing?: boolean;
|
playing?: boolean;
|
||||||
current?: boolean;
|
current?: boolean;
|
||||||
metadata?: LinkMetadata;
|
metadata?: LinkMetadata;
|
||||||
}
|
playbackError?: string;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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">
|
||||||
<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">
|
||||||
|
|||||||
Reference in New Issue
Block a user