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:
edde746
2026-08-03 02:34:37 +02:00
parent a9f0532f5f
commit 0f5e5c8b6e
6 changed files with 277 additions and 10 deletions
@@ -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: [
+76
View File
@@ -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() {}