feat(music): offer File Info on tracks and other file-backed items
The context menu only offered File Info for movies and episodes, so a track's path, container, and audio stream detail were unreachable even though both backends already answer getFileInfo for them. Gate the entry on the new MediaKind.hasFileInfo instead of a literal kind list: movies, episodes, tracks, and clips are leaf items with real files, while shows, seasons, artists, albums, collections, playlists, and folders carry no Media/MediaSources and would only ever produce the "not available" snackbar. Also fix the Plex stream classifier, which mapped streamType 4 to an embedded image although PlexStreamType.lyrics is 4. Only music tracks carry that type, so a track's lyric stream rendered under "Embedded Images" with the video field block. Type 5 was invented outright and is now unknown. close #1747
This commit is contained in:
@@ -30,6 +30,15 @@ enum MediaKind {
|
||||
|
||||
bool get isPlayable => isVideo || this == track;
|
||||
|
||||
/// Whether this kind maps to one or more files on disk, i.e. the server can
|
||||
/// answer `MediaServerClient.getFileInfo` for it.
|
||||
///
|
||||
/// Deliberately not folded into [isPlayable] even though the member sets
|
||||
/// coincide today: container kinds (show, season, artist, album, collection,
|
||||
/// playlist, folder) aggregate leaves and carry no `Media`/`MediaSources` of
|
||||
/// their own, while a non-playable kind can still be file-backed.
|
||||
bool get hasFileInfo => this == movie || this == episode || this == track || this == clip;
|
||||
|
||||
/// Whether this container kind derives watched state from aggregate leaves.
|
||||
bool get usesLeafWatchCounts => switch (this) {
|
||||
show || season || artist || album || collection || playlist || folder => true,
|
||||
|
||||
@@ -253,14 +253,17 @@ class JellyfinFileInfoStreamReader implements FileInfoStreamReader {
|
||||
// readers above do: one place to compare when a server adds a key.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Classify a Plex `Part.Stream[]` entry. Plex numbers stream types 1–5;
|
||||
/// lyrics/other types beyond that map to [MediaStreamKind.unknown].
|
||||
/// Classify a Plex `Part.Stream[]` entry. Plex numbers stream types 1–4
|
||||
/// ([PlexStreamType]); anything else maps to [MediaStreamKind.unknown].
|
||||
///
|
||||
/// Type 4 is lyrics, not an embedded image — music tracks are the only items
|
||||
/// that carry it, so the mismatch only became visible once File Info was
|
||||
/// offered for tracks.
|
||||
MediaStreamKind plexStreamKind(Map<String, dynamic> s) => switch (flexibleInt(s['streamType'])) {
|
||||
PlexStreamType.video => MediaStreamKind.video,
|
||||
PlexStreamType.audio => MediaStreamKind.audio,
|
||||
PlexStreamType.subtitle => MediaStreamKind.subtitle,
|
||||
4 => MediaStreamKind.image,
|
||||
5 => MediaStreamKind.data,
|
||||
PlexStreamType.lyrics => MediaStreamKind.lyric,
|
||||
_ => MediaStreamKind.unknown,
|
||||
};
|
||||
|
||||
|
||||
@@ -546,12 +546,15 @@ class MediaContextMenuState extends State<MediaContextMenu> {
|
||||
);
|
||||
}
|
||||
|
||||
// File Info (for episodes and movies). Backend-neutral — both
|
||||
// PlexClient and JellyfinClient implement [getFileInfo], reading
|
||||
// codec/stream metadata from `Media`/`MediaSources` respectively.
|
||||
// Hidden when the item has no backend marker so we don't fan out
|
||||
// to an arbitrary client.
|
||||
if (itemBackend != null && (mediaKind == MediaKind.episode || mediaKind == MediaKind.movie)) {
|
||||
// File Info — every file-backed leaf kind (movies, episodes, tracks,
|
||||
// clips). Backend-neutral: both PlexClient and JellyfinClient implement
|
||||
// [MediaServerClient.getFileInfo], reading codec/stream metadata from
|
||||
// `Media`/`MediaSources` respectively. Container kinds are excluded by
|
||||
// [MediaKind.hasFileInfo] because neither backend attaches media sources
|
||||
// to them — a show/season/album/artist entry would only ever produce the
|
||||
// "not available" snackbar. Hidden when the item has no backend marker
|
||||
// so we don't fan out to an arbitrary client.
|
||||
if (itemBackend != null && mediaKind != null && mediaKind.hasFileInfo) {
|
||||
menuActions.add(_MenuAction(value: 'fileinfo', icon: Symbols.info_rounded, label: t.mediaMenu.fileInfo));
|
||||
}
|
||||
|
||||
|
||||
@@ -266,6 +266,86 @@ void main() {
|
||||
expect(streams.map((stream) => stream.codec), ['h264', 'aac']);
|
||||
});
|
||||
|
||||
test('projects an audio-only track without inventing video fields', () {
|
||||
final result = parsePlexFileInfoFromJson({
|
||||
'Media': [
|
||||
{
|
||||
'id': 42,
|
||||
'container': 'flac',
|
||||
'bitrate': 989,
|
||||
'duration': 201000,
|
||||
'audioCodec': 'flac',
|
||||
'audioChannels': 2,
|
||||
'Part': [
|
||||
{
|
||||
'id': 'part-1',
|
||||
'file': '/music/Boards of Canada/Geogaddi/01 Ready Lets Go.flac',
|
||||
'size': 35651584,
|
||||
'container': 'flac',
|
||||
'Stream': [
|
||||
{
|
||||
'id': '201',
|
||||
'streamType': 2,
|
||||
'codec': 'flac',
|
||||
'channels': 2,
|
||||
'audioChannelLayout': 'stereo',
|
||||
'samplingRate': 44100,
|
||||
'bitDepth': 16,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
final version = result!.versions.single;
|
||||
expect(version.container, 'flac');
|
||||
expect(version.audioCodec, 'flac');
|
||||
expect(version.audioChannels, 2);
|
||||
expect(version.videoCodec, isNull);
|
||||
expect(version.videoResolutionLabel, isNull);
|
||||
expect(version.resolutionFormatted, isNull);
|
||||
expect(version.aspectRatioFormatted, isNull);
|
||||
|
||||
final part = version.parts.single;
|
||||
expect(part.filePath, '/music/Boards of Canada/Geogaddi/01 Ready Lets Go.flac');
|
||||
expect(part.fileSize, 35651584);
|
||||
expect(part.streamsOfKind(MediaStreamKind.video), isEmpty);
|
||||
|
||||
final audio = part.streamsOfKind(MediaStreamKind.audio).single;
|
||||
expect(audio.codec, 'flac');
|
||||
expect(audio.channelsFormatted, 'stereo (2 ch)');
|
||||
expect(audio.sampleRateFormatted, '44.1 kHz');
|
||||
expect(audio.bitDepthFormatted, '16 bit');
|
||||
});
|
||||
|
||||
test('classifies streamType 4 as lyrics rather than an embedded image', () {
|
||||
// Stream shapes copied from a live Plex music library: a sidecar LRC
|
||||
// arrives as streamType 4 with a `format` and a `/library/streams/{id}`
|
||||
// key, which the sheet renders as the Lyrics group.
|
||||
final streams = _plexStreams([
|
||||
{'id': '52586', 'streamType': 2, 'codec': 'flac'},
|
||||
{
|
||||
'id': '52594',
|
||||
'streamType': 4,
|
||||
'codec': 'lrc',
|
||||
'format': 'lrc',
|
||||
'key': '/library/streams/52594',
|
||||
'displayTitle': 'LRC',
|
||||
},
|
||||
{'id': '203', 'streamType': 9, 'codec': 'mystery'},
|
||||
]);
|
||||
|
||||
expect(streams.map((stream) => stream.kind), [
|
||||
MediaStreamKind.audio,
|
||||
MediaStreamKind.lyric,
|
||||
MediaStreamKind.unknown,
|
||||
]);
|
||||
expect(streams[1].codec, 'lrc');
|
||||
expect(streams[1].externalKey, '/library/streams/52594');
|
||||
});
|
||||
|
||||
test('returns null for null metadata or metadata without Media', () {
|
||||
expect(parsePlexFileInfoFromJson(null), isNull);
|
||||
expect(parsePlexFileInfoFromJson(const {}), isNull);
|
||||
@@ -459,6 +539,56 @@ void main() {
|
||||
expect(version.attachments.last.codec, 'explicit-codec');
|
||||
});
|
||||
|
||||
test('projects an audio-only media source without inventing video fields', () {
|
||||
final result = parseJellyfinFileInfoFromJson({
|
||||
'MediaSources': [
|
||||
{
|
||||
'Id': 'source-1',
|
||||
'Name': 'Ready Lets Go',
|
||||
'Container': 'flac',
|
||||
'Path': '/music/Boards of Canada/Geogaddi/01 Ready Lets Go.flac',
|
||||
'Size': 35651584,
|
||||
'Bitrate': 989000,
|
||||
'RunTimeTicks': 2010000000,
|
||||
'MediaStreams': [
|
||||
{
|
||||
'Type': 'Audio',
|
||||
'Codec': 'flac',
|
||||
'Channels': 2,
|
||||
'ChannelLayout': 'stereo',
|
||||
'SampleRate': 44100,
|
||||
'BitDepth': 16,
|
||||
},
|
||||
{'Type': 'EmbeddedImage', 'Codec': 'mjpeg'},
|
||||
{'Type': 'Lyric', 'Codec': 'lrc', 'Path': '/music/Boards of Canada/Geogaddi/01 Ready Lets Go.lrc'},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
final version = result!.versions.single;
|
||||
expect(version.container, 'flac');
|
||||
expect(version.audioCodec, 'flac');
|
||||
expect(version.videoCodec, isNull);
|
||||
expect(version.videoResolutionLabel, isNull);
|
||||
expect(version.resolutionFormatted, isNull);
|
||||
expect(version.aspectRatioFormatted, isNull);
|
||||
|
||||
final part = version.parts.single;
|
||||
expect(part.filePath, '/music/Boards of Canada/Geogaddi/01 Ready Lets Go.flac');
|
||||
expect(part.streamsOfKind(MediaStreamKind.video), isEmpty);
|
||||
|
||||
final audio = part.streamsOfKind(MediaStreamKind.audio).single;
|
||||
expect(audio.channelsFormatted, 'stereo (2 ch)');
|
||||
expect(audio.sampleRateFormatted, '44.1 kHz');
|
||||
expect(audio.bitDepthFormatted, '16 bit');
|
||||
|
||||
expect(part.streamsOfKind(MediaStreamKind.image).single.codec, 'mjpeg');
|
||||
final lyric = part.streamsOfKind(MediaStreamKind.lyric).single;
|
||||
expect(lyric.codec, 'lrc');
|
||||
expect(lyric.filePath, '/music/Boards of Canada/Geogaddi/01 Ready Lets Go.lrc');
|
||||
});
|
||||
|
||||
test('returns null for missing empty or non-list MediaSources', () {
|
||||
expect(parseJellyfinFileInfoFromJson(const {}), isNull);
|
||||
expect(parseJellyfinFileInfoFromJson(const {'MediaSources': []}), isNull);
|
||||
|
||||
@@ -42,6 +42,52 @@ void main() {
|
||||
expect(find.text('Subtitles'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('audio-only file drops the video section and keeps the copyable path', (tester) async {
|
||||
const path = '/music/Boards of Canada/Geogaddi/01 Ready Lets Go.flac';
|
||||
const fileInfo = MediaFileInfo(
|
||||
versions: [
|
||||
MediaFileVersion(
|
||||
container: 'flac',
|
||||
parts: [
|
||||
MediaFilePart(
|
||||
filePath: path,
|
||||
fileSize: 35651584,
|
||||
streams: [
|
||||
MediaStreamDetails(
|
||||
kind: MediaStreamKind.audio,
|
||||
ordinal: 1,
|
||||
codec: 'flac',
|
||||
channels: 2,
|
||||
channelLayout: 'stereo',
|
||||
sampleRate: 44100,
|
||||
bitDepth: 16,
|
||||
),
|
||||
MediaStreamDetails(kind: MediaStreamKind.lyric, ordinal: 1, codec: 'lrc'),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
await _pumpSheet(tester, fileInfo: fileInfo, title: 'Ready Lets Go');
|
||||
|
||||
expect(find.text('Ready Lets Go'), findsOneWidget);
|
||||
expect(find.text('File'), findsOneWidget);
|
||||
expect(find.text('Audio'), findsOneWidget);
|
||||
expect(find.text('Lyrics'), findsOneWidget);
|
||||
expect(find.text('stereo (2 ch)'), findsOneWidget);
|
||||
expect(find.text('44.1 kHz'), findsOneWidget);
|
||||
expect(find.text('16 bit'), findsOneWidget);
|
||||
expect(find.text(path), findsOneWidget);
|
||||
|
||||
// Nothing video-shaped may leak into a track's sheet.
|
||||
expect(find.text('Video'), findsNothing);
|
||||
expect(find.text('Embedded Images'), findsNothing);
|
||||
expect(find.text('Resolution'), findsNothing);
|
||||
expect(find.text('Frame Rate'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('absent fields and empty sections are omitted instead of rendering blank labels', (tester) async {
|
||||
const fileInfo = MediaFileInfo(
|
||||
versions: [
|
||||
|
||||
@@ -15,6 +15,7 @@ import 'package:plezy/navigation/profile_navigation_scope.dart';
|
||||
import 'package:plezy/media/ids.dart';
|
||||
import 'package:plezy/media/library_query.dart';
|
||||
import 'package:plezy/media/media_backend.dart';
|
||||
import 'package:plezy/media/media_file_info.dart';
|
||||
import 'package:plezy/media/media_item.dart';
|
||||
import 'package:plezy/media/media_kind.dart';
|
||||
import 'package:plezy/media/media_playlist.dart';
|
||||
@@ -43,6 +44,7 @@ import 'package:plezy/theme/mono_theme.dart';
|
||||
import 'package:plezy/utils/media_server_http_client.dart';
|
||||
import 'package:plezy/utils/media_server_timeouts.dart';
|
||||
import 'package:plezy/utils/platform_detector.dart';
|
||||
import 'package:plezy/widgets/file_info_bottom_sheet.dart';
|
||||
import 'package:plezy/widgets/media_context_menu.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../test_helpers/backend_client_fixtures.dart';
|
||||
@@ -692,6 +694,69 @@ void main() {
|
||||
expect(harness.music.playedTracks, [newerTrack]);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets('track file info fetches the tapped track and renders its audio-only sheet', (tester) async {
|
||||
const trackPath = '/music/Boards of Canada/Geogaddi/01 Ready Lets Go.flac';
|
||||
final track = testMediaItem(
|
||||
id: 'track-1',
|
||||
backend: MediaBackend.jellyfin,
|
||||
kind: MediaKind.track,
|
||||
title: 'Ready Lets Go',
|
||||
parentId: 'album-1',
|
||||
parentTitle: 'Geogaddi',
|
||||
grandparentId: 'artist-1',
|
||||
grandparentTitle: 'Boards of Canada',
|
||||
serverId: 'srv-1',
|
||||
);
|
||||
final harness = await _pumpSiblingMusicMenu(tester, item: track, relatedItems: const []);
|
||||
harness.client.fileInfo = const MediaFileInfo(
|
||||
versions: [
|
||||
MediaFileVersion(
|
||||
container: 'flac',
|
||||
parts: [
|
||||
MediaFilePart(
|
||||
filePath: trackPath,
|
||||
fileSize: 35651584,
|
||||
streams: [MediaStreamDetails(kind: MediaStreamKind.audio, ordinal: 1, codec: 'flac', channels: 2)],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
await _selectSiblingMusicMenuAction(tester, harness, t.mediaMenu.fileInfo);
|
||||
|
||||
expect(harness.client.fileInfoRequests, [same(track)]);
|
||||
expect(find.byType(FileInfoBottomSheet), findsOneWidget);
|
||||
expect(find.text('Ready Lets Go'), findsOneWidget);
|
||||
expect(find.text(trackPath), findsOneWidget);
|
||||
expect(find.text(t.fileInfo.audio), findsOneWidget);
|
||||
expect(find.text(t.fileInfo.video), findsNothing);
|
||||
// The loading dialog must be gone and no error snackbar raised.
|
||||
expect(find.byType(CircularProgressIndicator), findsNothing);
|
||||
expect(find.byType(SnackBar), findsNothing);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets('album menu omits file info because containers carry no media sources', (tester) async {
|
||||
final album = testMediaItem(
|
||||
id: 'album-1',
|
||||
backend: MediaBackend.jellyfin,
|
||||
kind: MediaKind.album,
|
||||
title: 'Geogaddi',
|
||||
parentId: 'artist-1',
|
||||
parentTitle: 'Boards of Canada',
|
||||
serverId: 'srv-1',
|
||||
);
|
||||
final harness = await _pumpSiblingMusicMenu(tester, item: album, relatedItems: const []);
|
||||
|
||||
harness.menuKey.currentState!.showContextMenu(tester.element(find.text('mini-player menu target')));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text(t.music.playNext), findsOneWidget);
|
||||
expect(find.text(t.mediaMenu.fileInfo), findsNothing);
|
||||
expect(harness.client.fileInfoRequests, isEmpty);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -977,6 +1042,17 @@ class _RelatedMusicClient implements MediaServerClient {
|
||||
@override
|
||||
Future<List<MediaItem>> fetchArtistAlbums(MediaItem artist) async => const [];
|
||||
|
||||
/// Items the menu asked file info for, in call order — the track menu must
|
||||
/// resolve the client for the tapped item, not for whatever is playing.
|
||||
final List<MediaItem> fileInfoRequests = [];
|
||||
MediaFileInfo? fileInfo;
|
||||
|
||||
@override
|
||||
Future<MediaFileInfo?> getFileInfo(MediaItem item) async {
|
||||
fileInfoRequests.add(item);
|
||||
return fileInfo;
|
||||
}
|
||||
|
||||
@override
|
||||
void close() {}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user