refactor: deduplicate
This commit is contained in:
@@ -0,0 +1,17 @@
|
||||
import 'plex_metadata.dart';
|
||||
|
||||
/// Extension on PlexMetadata for type checking convenience methods
|
||||
extension PlexMetadataType on PlexMetadata {
|
||||
bool get isShow => type.toLowerCase() == 'show';
|
||||
bool get isMovie => type.toLowerCase() == 'movie';
|
||||
bool get isSeason => type.toLowerCase() == 'season';
|
||||
bool get isEpisode => type.toLowerCase() == 'episode';
|
||||
bool get isArtist => type.toLowerCase() == 'artist';
|
||||
bool get isAlbum => type.toLowerCase() == 'album';
|
||||
bool get isTrack => type.toLowerCase() == 'track';
|
||||
bool get isCollection => type.toLowerCase() == 'collection';
|
||||
bool get isPlaylist => type.toLowerCase() == 'playlist';
|
||||
bool get isClip => type.toLowerCase() == 'clip';
|
||||
bool get isMusicContent => isArtist || isAlbum || isTrack;
|
||||
bool get isVideoContent => isShow || isMovie || isSeason || isEpisode;
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import '../widgets/focused_scroll_scaffold.dart';
|
||||
import '../i18n/strings.g.dart';
|
||||
import '../utils/dialogs.dart';
|
||||
import '../utils/app_logger.dart';
|
||||
import '../utils/snackbar_helper.dart';
|
||||
import 'base_media_list_detail_screen.dart';
|
||||
|
||||
/// Screen to display the contents of a collection
|
||||
@@ -55,9 +56,7 @@ class _CollectionDetailScreenState
|
||||
|
||||
if (sectionId == null) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(t.collections.unknownLibrarySection)),
|
||||
);
|
||||
showErrorSnackBar(context, t.collections.unknownLibrarySection);
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -82,28 +81,21 @@ class _CollectionDetailScreenState
|
||||
|
||||
if (mounted) {
|
||||
if (success) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(t.collections.deleted)));
|
||||
showSuccessSnackBar(context, t.collections.deleted);
|
||||
Navigator.pop(
|
||||
context,
|
||||
true,
|
||||
); // Return true to indicate refresh needed
|
||||
} else {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(t.collections.deleteFailed)));
|
||||
showErrorSnackBar(context, t.collections.deleteFailed);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
appLogger.e('Failed to delete collection', error: e);
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
t.collections.deleteFailedWithError(error: e.toString()),
|
||||
),
|
||||
),
|
||||
showErrorSnackBar(
|
||||
context,
|
||||
t.collections.deleteFailedWithError(error: e.toString()),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import 'package:cached_network_image/cached_network_image.dart';
|
||||
import '../../services/plex_client.dart';
|
||||
import '../utils/plex_image_helper.dart';
|
||||
import '../models/plex_metadata.dart';
|
||||
import '../models/plex_metadata_extensions.dart';
|
||||
import '../models/plex_hub.dart';
|
||||
import '../providers/multi_server_provider.dart';
|
||||
import '../providers/server_state_provider.dart';
|
||||
@@ -1187,13 +1188,13 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
||||
}
|
||||
|
||||
Widget _buildHeroItem(PlexMetadata heroItem) {
|
||||
final isEpisode = heroItem.type.toLowerCase() == 'episode';
|
||||
final isEpisode = heroItem.isEpisode;
|
||||
final showName = heroItem.grandparentTitle ?? heroItem.title;
|
||||
final screenWidth = MediaQuery.of(context).size.width;
|
||||
final isLargeScreen = ScreenBreakpoints.isWideTabletOrLarger(screenWidth);
|
||||
|
||||
// Determine content type label for chip
|
||||
final contentTypeLabel = heroItem.type.toLowerCase() == 'movie'
|
||||
final contentTypeLabel = heroItem.isMovie
|
||||
? t.discover.movie
|
||||
: t.discover.tvShow;
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import '../../models/plex_metadata.dart';
|
||||
import '../../utils/app_logger.dart';
|
||||
import '../../utils/media_navigation_helper.dart';
|
||||
import '../../utils/provider_extensions.dart';
|
||||
import '../../utils/snackbar_helper.dart';
|
||||
import '../../i18n/strings.g.dart';
|
||||
import 'folder_tree_item.dart';
|
||||
import 'empty_state_widget.dart';
|
||||
@@ -125,14 +126,11 @@ class _FolderTreeViewState extends State<FolderTreeView> {
|
||||
});
|
||||
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
t.errors.failedToLoad(
|
||||
context: t.libraries.folders,
|
||||
error: e.toString(),
|
||||
),
|
||||
),
|
||||
showErrorSnackBar(
|
||||
context,
|
||||
t.errors.failedToLoad(
|
||||
context: t.libraries.folders,
|
||||
error: e.toString(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -16,6 +16,8 @@ import '../../providers/multi_server_provider.dart';
|
||||
import '../../utils/app_logger.dart';
|
||||
import '../../utils/platform_detector.dart';
|
||||
import '../../utils/provider_extensions.dart';
|
||||
import '../../utils/snackbar_helper.dart';
|
||||
import '../../utils/content_type_helper.dart';
|
||||
import '../../widgets/desktop_app_bar.dart';
|
||||
import '../../widgets/focusable_tab_chip.dart';
|
||||
import '../main_screen.dart';
|
||||
@@ -351,7 +353,7 @@ class _LibrariesScreenState extends State<LibrariesScreen>
|
||||
// Filter out music libraries (type: 'artist') since music playback is not yet supported
|
||||
// Only show movie and TV show libraries
|
||||
final filteredLibraries = allLibraries
|
||||
.where((lib) => lib.type.toLowerCase() != 'artist')
|
||||
.where((lib) => !ContentTypeHelper.isMusicLibrary(lib))
|
||||
.toList();
|
||||
|
||||
// Load saved library order and apply it
|
||||
@@ -864,34 +866,22 @@ class _LibrariesScreenState extends State<LibrariesScreen>
|
||||
final client = context.getClientForLibrary(library);
|
||||
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(progressMessage),
|
||||
duration: const Duration(seconds: 2),
|
||||
),
|
||||
showAppSnackBar(
|
||||
context,
|
||||
progressMessage,
|
||||
duration: const Duration(seconds: 2),
|
||||
);
|
||||
}
|
||||
|
||||
await action(client);
|
||||
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(successMessage),
|
||||
duration: const Duration(seconds: 3),
|
||||
),
|
||||
);
|
||||
showSuccessSnackBar(context, successMessage);
|
||||
}
|
||||
} catch (e) {
|
||||
appLogger.e('Library action failed', error: e);
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(failureMessage(e)),
|
||||
backgroundColor: Colors.red,
|
||||
duration: const Duration(seconds: 3),
|
||||
),
|
||||
);
|
||||
showErrorSnackBar(context, failureMessage(e));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import '../widgets/plex_optimized_image.dart';
|
||||
import '../utils/plex_image_helper.dart';
|
||||
import '../../services/plex_client.dart';
|
||||
import '../models/plex_metadata.dart';
|
||||
import '../models/plex_metadata_extensions.dart';
|
||||
import '../models/download_status.dart';
|
||||
import '../providers/playback_state_provider.dart';
|
||||
import '../providers/download_provider.dart';
|
||||
@@ -22,6 +23,7 @@ import '../utils/app_logger.dart';
|
||||
import '../utils/content_rating_formatter.dart';
|
||||
import '../utils/duration_formatter.dart';
|
||||
import '../utils/provider_extensions.dart';
|
||||
import '../utils/snackbar_helper.dart';
|
||||
import '../utils/video_player_navigation.dart';
|
||||
import '../widgets/app_bar_back_button.dart';
|
||||
import '../utils/desktop_window_padding.dart';
|
||||
@@ -139,7 +141,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> {
|
||||
onPressed: () async {
|
||||
// For TV shows, play the OnDeck episode if available
|
||||
// Otherwise, play the first episode of the first season
|
||||
if (metadata.type.toLowerCase() == 'show') {
|
||||
if (metadata.isShow) {
|
||||
if (_onDeckEpisode != null) {
|
||||
appLogger.d(
|
||||
'Playing on deck episode: ${_onDeckEpisode!.title}',
|
||||
@@ -195,8 +197,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> {
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
// Shuffle button (only for shows and seasons)
|
||||
if (metadata.type.toLowerCase() == 'show' ||
|
||||
metadata.type.toLowerCase() == 'season') ...[
|
||||
if (metadata.isShow || metadata.isSeason) ...[
|
||||
IconButton.filledTonal(
|
||||
onPressed: () async {
|
||||
await _handleShufflePlayWithQueue(
|
||||
@@ -318,13 +319,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> {
|
||||
client,
|
||||
);
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Download resumed'),
|
||||
),
|
||||
);
|
||||
showAppSnackBar(context, 'Download resumed');
|
||||
}
|
||||
},
|
||||
icon: const AppIcon(
|
||||
@@ -361,28 +356,16 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> {
|
||||
);
|
||||
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(
|
||||
showSuccessSnackBar(
|
||||
context,
|
||||
).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
t.downloads.downloadQueued,
|
||||
),
|
||||
),
|
||||
t.downloads.downloadQueued,
|
||||
);
|
||||
}
|
||||
} on CellularDownloadBlockedException {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(
|
||||
showErrorSnackBar(
|
||||
context,
|
||||
).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
t
|
||||
.settings
|
||||
.cellularDownloadBlocked,
|
||||
),
|
||||
),
|
||||
t.settings.cellularDownloadBlocked,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -436,14 +419,9 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> {
|
||||
globalKey,
|
||||
);
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(
|
||||
showSuccessSnackBar(
|
||||
context,
|
||||
).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
t.downloads.downloadDeleted,
|
||||
),
|
||||
),
|
||||
t.downloads.downloadDeleted,
|
||||
);
|
||||
}
|
||||
} else if (action == 'retry' &&
|
||||
@@ -461,28 +439,16 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> {
|
||||
client,
|
||||
);
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(
|
||||
showSuccessSnackBar(
|
||||
context,
|
||||
).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
t.downloads.downloadQueued,
|
||||
),
|
||||
),
|
||||
t.downloads.downloadQueued,
|
||||
);
|
||||
}
|
||||
} on CellularDownloadBlockedException {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(
|
||||
showErrorSnackBar(
|
||||
context,
|
||||
).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
t
|
||||
.settings
|
||||
.cellularDownloadBlocked,
|
||||
),
|
||||
),
|
||||
t.settings.cellularDownloadBlocked,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -526,11 +492,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> {
|
||||
count: count,
|
||||
)
|
||||
: 'All episodes already downloaded';
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(
|
||||
SnackBar(content: Text(message)),
|
||||
);
|
||||
showAppSnackBar(context, message);
|
||||
}
|
||||
},
|
||||
tooltip: tooltip,
|
||||
@@ -584,14 +546,9 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> {
|
||||
globalKey,
|
||||
);
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(
|
||||
showSuccessSnackBar(
|
||||
context,
|
||||
).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
t.downloads.downloadDeleted,
|
||||
),
|
||||
),
|
||||
t.downloads.downloadDeleted,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -623,9 +580,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> {
|
||||
count: count,
|
||||
)
|
||||
: t.downloads.downloadQueued;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(message)),
|
||||
);
|
||||
showSuccessSnackBar(context, message);
|
||||
}
|
||||
},
|
||||
icon: const AppIcon(
|
||||
@@ -663,16 +618,11 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> {
|
||||
);
|
||||
}
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
isWatched
|
||||
? t
|
||||
.messages
|
||||
.markedAsUnwatchedOffline
|
||||
: t.messages.markedAsWatchedOffline,
|
||||
),
|
||||
),
|
||||
showAppSnackBar(
|
||||
context,
|
||||
isWatched
|
||||
? t.messages.markedAsUnwatchedOffline
|
||||
: t.messages.markedAsWatchedOffline,
|
||||
);
|
||||
// Refresh offline OnDeck
|
||||
_loadOfflineOnDeckEpisode();
|
||||
@@ -693,14 +643,11 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> {
|
||||
}
|
||||
if (context.mounted) {
|
||||
_watchStateChanged = true;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
isWatched
|
||||
? t.messages.markedAsUnwatched
|
||||
: t.messages.markedAsWatched,
|
||||
),
|
||||
),
|
||||
showSuccessSnackBar(
|
||||
context,
|
||||
isWatched
|
||||
? t.messages.markedAsUnwatched
|
||||
: t.messages.markedAsWatched,
|
||||
);
|
||||
// Update watch state without full rebuild
|
||||
_updateWatchState();
|
||||
@@ -708,14 +655,9 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> {
|
||||
}
|
||||
} catch (e) {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
t.messages.errorLoading(
|
||||
error: e.toString(),
|
||||
),
|
||||
),
|
||||
),
|
||||
showErrorSnackBar(
|
||||
context,
|
||||
t.messages.errorLoading(error: e.toString()),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -795,14 +737,12 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> {
|
||||
|
||||
// Offline mode: use passed metadata directly, load seasons from downloads
|
||||
if (widget.isOffline) {
|
||||
final type = widget.metadata.type.toLowerCase();
|
||||
|
||||
setState(() {
|
||||
_fullMetadata = widget.metadata;
|
||||
_isLoadingMetadata = false;
|
||||
});
|
||||
|
||||
if (type == 'show') {
|
||||
if (widget.metadata.isShow) {
|
||||
_loadSeasonsFromDownloads();
|
||||
// Get offline OnDeck episode
|
||||
_loadOfflineOnDeckEpisode();
|
||||
@@ -847,7 +787,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> {
|
||||
});
|
||||
|
||||
// Load seasons if it's a show
|
||||
if (metadata.type.toLowerCase() == 'show') {
|
||||
if (metadata.isShow) {
|
||||
_loadSeasons();
|
||||
}
|
||||
return;
|
||||
@@ -859,7 +799,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> {
|
||||
_isLoadingMetadata = false;
|
||||
});
|
||||
|
||||
if (widget.metadata.type.toLowerCase() == 'show') {
|
||||
if (widget.metadata.isShow) {
|
||||
_loadSeasons();
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -869,7 +809,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> {
|
||||
_isLoadingMetadata = false;
|
||||
});
|
||||
|
||||
if (widget.metadata.type.toLowerCase() == 'show') {
|
||||
if (widget.metadata.isShow) {
|
||||
_loadSeasons();
|
||||
}
|
||||
}
|
||||
@@ -1083,7 +1023,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> {
|
||||
|
||||
// For shows, also refetch seasons to update their watch counts
|
||||
List<PlexMetadata>? updatedSeasons;
|
||||
if (metadata.type.toLowerCase() == 'show') {
|
||||
if (metadata.isShow) {
|
||||
final seasons = await client.getChildren(widget.metadata.ratingKey);
|
||||
// Preserve serverId for each season
|
||||
updatedSeasons = seasons
|
||||
@@ -1130,9 +1070,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> {
|
||||
|
||||
if (_seasons.isEmpty) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(t.messages.noSeasonsFound)));
|
||||
showErrorSnackBar(context, t.messages.noSeasonsFound);
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -1163,9 +1101,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> {
|
||||
|
||||
if (episodes.isEmpty) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(t.messages.noEpisodesFound)));
|
||||
showErrorSnackBar(context, t.messages.noEpisodesFound);
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -1192,8 +1128,9 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> {
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(t.messages.errorLoading(error: e.toString()))),
|
||||
showErrorSnackBar(
|
||||
context,
|
||||
t.messages.errorLoading(error: e.toString()),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1208,9 +1145,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> {
|
||||
// Shuffle requires server connectivity
|
||||
if (widget.isOffline) {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Shuffle not available offline')),
|
||||
);
|
||||
showErrorSnackBar(context, 'Shuffle not available offline');
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -1219,7 +1154,6 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> {
|
||||
if (client == null) return;
|
||||
|
||||
final playbackState = context.read<PlaybackStateProvider>();
|
||||
final itemType = metadata.type.toLowerCase();
|
||||
|
||||
try {
|
||||
// Show loading indicator
|
||||
@@ -1234,9 +1168,9 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> {
|
||||
|
||||
// Determine the rating key for the play queue
|
||||
String showRatingKey;
|
||||
if (itemType == 'show') {
|
||||
if (metadata.isShow) {
|
||||
showRatingKey = metadata.ratingKey;
|
||||
} else if (itemType == 'season') {
|
||||
} else if (metadata.isSeason) {
|
||||
// For seasons, we need the show's rating key
|
||||
// The season's parentRatingKey should point to the show
|
||||
if (metadata.parentRatingKey == null) {
|
||||
@@ -1262,9 +1196,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> {
|
||||
playQueue.items == null ||
|
||||
playQueue.items!.isEmpty) {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(t.messages.noEpisodesFound)));
|
||||
showErrorSnackBar(context, t.messages.noEpisodesFound);
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -1298,8 +1230,9 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> {
|
||||
}
|
||||
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(t.messages.errorLoading(error: e.toString()))),
|
||||
showErrorSnackBar(
|
||||
context,
|
||||
t.messages.errorLoading(error: e.toString()),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1309,7 +1242,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> {
|
||||
Widget build(BuildContext context) {
|
||||
// Use full metadata if loaded, otherwise use passed metadata
|
||||
final metadata = _fullMetadata ?? widget.metadata;
|
||||
final isShow = metadata.type.toLowerCase() == 'show';
|
||||
final isShow = metadata.isShow;
|
||||
|
||||
KeyEventResult handleBack(FocusNode _, KeyEvent event) =>
|
||||
handleBackKeyNavigation(context, event, result: _watchStateChanged);
|
||||
@@ -1886,7 +1819,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> {
|
||||
|
||||
String _getPlayButtonLabel(PlexMetadata metadata) {
|
||||
// For TV shows - use compact S1E1 format
|
||||
if (metadata.type.toLowerCase() == 'show') {
|
||||
if (metadata.isShow) {
|
||||
if (_onDeckEpisode != null) {
|
||||
final episode = _onDeckEpisode!;
|
||||
final seasonNum = episode.parentIndex ?? 0;
|
||||
@@ -1910,7 +1843,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> {
|
||||
|
||||
IconData _getPlayButtonIcon(PlexMetadata metadata) {
|
||||
// For TV shows
|
||||
if (metadata.type.toLowerCase() == 'show') {
|
||||
if (metadata.isShow) {
|
||||
if (_onDeckEpisode != null) {
|
||||
final episode = _onDeckEpisode!;
|
||||
// Check if episode has been partially watched
|
||||
|
||||
@@ -7,6 +7,7 @@ import '../../utils/duration_formatter.dart';
|
||||
import '../../utils/provider_extensions.dart';
|
||||
import '../../i18n/strings.g.dart';
|
||||
import '../../widgets/media_context_menu.dart';
|
||||
import '../../widgets/media_progress_bar.dart';
|
||||
import '../../widgets/plex_optimized_image.dart';
|
||||
|
||||
/// Custom list item widget for playlist items
|
||||
@@ -102,12 +103,9 @@ class _PlaylistItemCardState extends State<PlaylistItemCard> {
|
||||
if (item.viewOffset != null && item.duration != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 6),
|
||||
child: LinearProgressIndicator(
|
||||
value: item.viewOffset! / item.duration!,
|
||||
backgroundColor: Colors.grey[800],
|
||||
valueColor: AlwaysStoppedAnimation<Color>(
|
||||
Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
child: MediaProgressBar(
|
||||
viewOffset: item.viewOffset!,
|
||||
duration: item.duration!,
|
||||
minHeight: 3,
|
||||
),
|
||||
),
|
||||
|
||||
@@ -5,6 +5,7 @@ import 'package:provider/provider.dart';
|
||||
import '../../models/plex_home_user.dart';
|
||||
import '../../providers/user_profile_provider.dart';
|
||||
import '../../utils/provider_extensions.dart';
|
||||
import '../../utils/snackbar_helper.dart';
|
||||
import 'profile_list_tile.dart';
|
||||
import '../../widgets/desktop_app_bar.dart';
|
||||
import '../../i18n/strings.g.dart';
|
||||
@@ -117,13 +118,9 @@ class ProfileSwitchScreen extends StatelessWidget {
|
||||
if (success && context.mounted) {
|
||||
Navigator.of(context).pop();
|
||||
} else if (!success && context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
t.errors.failedToSwitchProfile(displayName: user.displayName),
|
||||
),
|
||||
backgroundColor: Theme.of(context).colorScheme.error,
|
||||
),
|
||||
showErrorSnackBar(
|
||||
context,
|
||||
t.errors.failedToSwitchProfile(displayName: user.displayName),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import '../providers/multi_server_provider.dart';
|
||||
import '../providers/settings_provider.dart';
|
||||
import '../utils/app_logger.dart';
|
||||
import '../utils/sliver_adaptive_media_builder.dart';
|
||||
import '../utils/snackbar_helper.dart';
|
||||
import '../widgets/desktop_app_bar.dart';
|
||||
import '../widgets/media_card.dart';
|
||||
|
||||
@@ -115,9 +116,7 @@ class _SearchScreenState extends State<SearchScreen>
|
||||
setState(() {
|
||||
_isSearching = false;
|
||||
});
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(t.errors.searchFailed(error: e))),
|
||||
);
|
||||
showErrorSnackBar(context, t.errors.searchFailed(error: e));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import '../../providers/theme_provider.dart';
|
||||
import '../../services/keyboard_shortcuts_service.dart';
|
||||
import '../../services/settings_service.dart' as settings;
|
||||
import '../../services/update_service.dart';
|
||||
import '../../utils/snackbar_helper.dart';
|
||||
import '../../widgets/desktop_app_bar.dart';
|
||||
import 'hotkey_recorder_widget.dart';
|
||||
import 'about_screen.dart';
|
||||
@@ -493,9 +494,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
.isDirectoryWritable(dir);
|
||||
if (!isWritable) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(t.settings.downloadLocationInvalid)),
|
||||
);
|
||||
showErrorSnackBar(context, t.settings.downloadLocationInvalid);
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -510,16 +509,12 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
|
||||
if (mounted) {
|
||||
setState(() {});
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(t.settings.downloadLocationChanged)),
|
||||
);
|
||||
showSuccessSnackBar(context, t.settings.downloadLocationChanged);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(t.settings.downloadLocationSelectError)),
|
||||
);
|
||||
showErrorSnackBar(context, t.settings.downloadLocationSelectError);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -530,9 +525,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
|
||||
if (mounted) {
|
||||
setState(() {});
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(t.settings.downloadLocationReset)));
|
||||
showAppSnackBar(context, t.settings.downloadLocationReset);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1254,12 +1247,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
|
||||
if (updateInfo == null || updateInfo['hasUpdate'] != true) {
|
||||
// Show "no updates" message
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(t.update.latestVersion),
|
||||
duration: Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
showAppSnackBar(context, t.update.latestVersion);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -1268,12 +1256,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
_isCheckingForUpdate = false;
|
||||
});
|
||||
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(t.update.checkFailed),
|
||||
duration: Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
showErrorSnackBar(context, t.update.checkFailed);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import '../../services/plex_client.dart';
|
||||
import '../services/plex_api_cache.dart';
|
||||
import '../models/plex_media_version.dart';
|
||||
import '../models/plex_metadata.dart';
|
||||
import '../models/plex_metadata_extensions.dart';
|
||||
import '../models/plex_media_info.dart';
|
||||
import '../providers/download_provider.dart';
|
||||
import '../providers/playback_state_provider.dart';
|
||||
@@ -32,6 +33,7 @@ import '../utils/orientation_helper.dart';
|
||||
import '../utils/platform_detector.dart';
|
||||
import '../utils/provider_extensions.dart';
|
||||
import '../utils/language_codes.dart';
|
||||
import '../utils/snackbar_helper.dart';
|
||||
import '../utils/video_player_navigation.dart';
|
||||
import '../widgets/video_controls/video_controls.dart';
|
||||
import '../i18n/strings.g.dart';
|
||||
@@ -545,7 +547,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen>
|
||||
|
||||
// Set controls enabled based on content type
|
||||
final playbackState = context.read<PlaybackStateProvider>();
|
||||
final isEpisode = widget.metadata.type.toLowerCase() == 'episode';
|
||||
final isEpisode = widget.metadata.isEpisode;
|
||||
final isInPlaylist = playbackState.isPlaylistActive;
|
||||
|
||||
await _mediaControlsManager!.setControlsEnabled(
|
||||
@@ -576,7 +578,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen>
|
||||
if (widget.isOffline) return;
|
||||
|
||||
// Only create play queues for episodes
|
||||
if (widget.metadata.type.toLowerCase() != 'episode') {
|
||||
if (!widget.metadata.isEpisode) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -748,9 +750,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen>
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(t.messages.errorLoading(error: e.toString()))),
|
||||
);
|
||||
showErrorSnackBar(context, t.messages.errorLoading(error: e.toString()));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1022,7 +1022,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen>
|
||||
}
|
||||
}
|
||||
|
||||
final isEpisode = widget.metadata.type.toLowerCase() == 'episode';
|
||||
final isEpisode = widget.metadata.isEpisode;
|
||||
final languagePrefRatingKey = isEpisode
|
||||
? (widget.metadata.grandparentRatingKey ?? widget.metadata.ratingKey)
|
||||
: widget.metadata.ratingKey;
|
||||
@@ -1145,7 +1145,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen>
|
||||
}
|
||||
|
||||
// Determine ratingKeys
|
||||
final isEpisode = widget.metadata.type.toLowerCase() == 'episode';
|
||||
final isEpisode = widget.metadata.isEpisode;
|
||||
final languagePrefRatingKey = isEpisode
|
||||
? (widget.metadata.grandparentRatingKey ?? widget.metadata.ratingKey)
|
||||
: widget.metadata.ratingKey;
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'package:rate_limiter/rate_limiter.dart';
|
||||
|
||||
import 'plex_client.dart';
|
||||
import '../models/plex_metadata.dart';
|
||||
import '../models/plex_metadata_extensions.dart';
|
||||
import '../utils/app_logger.dart';
|
||||
|
||||
/// Manages OS media controls integration for video playback.
|
||||
@@ -175,7 +176,7 @@ class MediaControlsManager {
|
||||
/// For movies: Director or studio
|
||||
/// For other content: Fallback to year or empty
|
||||
String _buildArtist(PlexMetadata metadata) {
|
||||
if (metadata.type.toLowerCase() == 'episode') {
|
||||
if (metadata.isEpisode) {
|
||||
final parts = <String>[];
|
||||
|
||||
// Add show name
|
||||
@@ -191,7 +192,7 @@ class MediaControlsManager {
|
||||
}
|
||||
|
||||
return parts.join(' • ');
|
||||
} else if (metadata.type.toLowerCase() == 'movie') {
|
||||
} else if (metadata.isMovie) {
|
||||
// For movies, use director or studio
|
||||
// Note: These fields may need to be added to PlexMetadata model
|
||||
if (metadata.year != null) {
|
||||
|
||||
@@ -6,6 +6,7 @@ import '../models/plex_metadata.dart';
|
||||
import '../models/plex_playlist.dart';
|
||||
import '../providers/playback_state_provider.dart';
|
||||
import '../utils/app_logger.dart';
|
||||
import '../utils/snackbar_helper.dart';
|
||||
import '../utils/video_player_navigation.dart';
|
||||
import '../i18n/strings.g.dart';
|
||||
import 'plex_client.dart';
|
||||
@@ -291,9 +292,7 @@ class PlayQueueLauncher {
|
||||
|
||||
// Handle empty queue result
|
||||
if (result is PlayQueueEmpty && context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(t.messages.failedToCreatePlayQueueNoItems)),
|
||||
);
|
||||
showErrorSnackBar(context, t.messages.failedToCreatePlayQueueNoItems);
|
||||
}
|
||||
|
||||
await dismissLoading();
|
||||
@@ -302,12 +301,9 @@ class PlayQueueLauncher {
|
||||
appLogger.e('Failed to $action', error: e);
|
||||
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
t.messages.failedPlayback(action: action, error: e.toString()),
|
||||
),
|
||||
),
|
||||
showErrorSnackBar(
|
||||
context,
|
||||
t.messages.failedPlayback(action: action, error: e.toString()),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+297
-340
@@ -11,6 +11,7 @@ import '../models/plex_library.dart';
|
||||
import '../models/plex_media_info.dart';
|
||||
import '../models/plex_media_version.dart';
|
||||
import '../models/plex_metadata.dart';
|
||||
import '../models/plex_metadata_extensions.dart';
|
||||
import '../models/plex_playlist.dart';
|
||||
import '../models/plex_sort.dart';
|
||||
import '../models/plex_video_playback_data.dart';
|
||||
@@ -19,6 +20,13 @@ import '../utils/app_logger.dart';
|
||||
import '../utils/log_redaction_manager.dart';
|
||||
import 'plex_api_cache.dart';
|
||||
|
||||
/// Constants for Plex stream types
|
||||
class PlexStreamType {
|
||||
static const int video = 1;
|
||||
static const int audio = 2;
|
||||
static const int subtitle = 3;
|
||||
}
|
||||
|
||||
/// Result of testing a connection, including success status and latency
|
||||
class ConnectionTestResult {
|
||||
final bool success;
|
||||
@@ -402,74 +410,53 @@ class PlexClient {
|
||||
// Cache key is always the base endpoint (no query params)
|
||||
final cacheKey = '/library/metadata/$ratingKey';
|
||||
|
||||
// If offline mode, return from cache only (no OnDeck in offline)
|
||||
if (_offlineMode) {
|
||||
final cached = await _cache.get(serverId, cacheKey);
|
||||
if (cached != null) {
|
||||
final metadata = _parseMetadataWithImagesFromCachedResponse(cached);
|
||||
return {'metadata': metadata, 'onDeckEpisode': null};
|
||||
}
|
||||
return {'metadata': null, 'onDeckEpisode': null};
|
||||
}
|
||||
|
||||
// Online: try network first
|
||||
try {
|
||||
// Fetch with chapters/markers/onDeck
|
||||
final response = await _dio.get(
|
||||
// Special handling needed for OnDeck - can't use simple _fetchWithCacheFallback
|
||||
// because OnDeck is only available from network response, not cache
|
||||
return await _fetchWithCacheFallback<Map<String, dynamic>>(
|
||||
cacheKey: cacheKey,
|
||||
networkCall: () => _dio.get(
|
||||
'/library/metadata/$ratingKey',
|
||||
queryParameters: {
|
||||
'includeChapters': 1,
|
||||
'includeMarkers': 1,
|
||||
'includeOnDeck': 1,
|
||||
},
|
||||
);
|
||||
),
|
||||
parseCache: (cachedData) {
|
||||
final metadata = _parseMetadataWithImagesFromCachedResponse(cachedData);
|
||||
return {'metadata': metadata, 'onDeckEpisode': null};
|
||||
},
|
||||
parseResponse: (response) {
|
||||
PlexMetadata? metadata;
|
||||
PlexMetadata? onDeckEpisode;
|
||||
|
||||
// Cache at base endpoint (single cache entry per item)
|
||||
if (response.data != null) {
|
||||
await _cache.put(serverId, cacheKey, response.data);
|
||||
}
|
||||
final metadataJson = _getFirstMetadataJson(response);
|
||||
|
||||
PlexMetadata? metadata;
|
||||
PlexMetadata? onDeckEpisode;
|
||||
if (metadataJson != null) {
|
||||
metadata = PlexMetadata.fromJsonWithImages(
|
||||
metadataJson,
|
||||
).copyWith(serverId: serverId, serverName: serverName);
|
||||
|
||||
final metadataJson = _getFirstMetadataJson(response);
|
||||
// Check if OnDeck is nested inside Metadata
|
||||
if (metadataJson.containsKey('OnDeck') &&
|
||||
metadataJson['OnDeck'] != null) {
|
||||
final onDeckData = metadataJson['OnDeck'];
|
||||
|
||||
if (metadataJson != null) {
|
||||
metadata = PlexMetadata.fromJsonWithImages(
|
||||
metadataJson,
|
||||
).copyWith(serverId: serverId, serverName: serverName);
|
||||
|
||||
// Check if OnDeck is nested inside Metadata
|
||||
if (metadataJson.containsKey('OnDeck') &&
|
||||
metadataJson['OnDeck'] != null) {
|
||||
final onDeckData = metadataJson['OnDeck'];
|
||||
|
||||
// OnDeck can be either a Map with 'Metadata' key or direct metadata
|
||||
if (onDeckData is Map && onDeckData.containsKey('Metadata')) {
|
||||
final onDeckMetadata = onDeckData['Metadata'];
|
||||
if (onDeckMetadata != null) {
|
||||
onDeckEpisode = PlexMetadata.fromJson(
|
||||
onDeckMetadata,
|
||||
).copyWith(serverId: serverId, serverName: serverName);
|
||||
// OnDeck can be either a Map with 'Metadata' key or direct metadata
|
||||
if (onDeckData is Map && onDeckData.containsKey('Metadata')) {
|
||||
final onDeckMetadata = onDeckData['Metadata'];
|
||||
if (onDeckMetadata != null) {
|
||||
onDeckEpisode = PlexMetadata.fromJson(
|
||||
onDeckMetadata,
|
||||
).copyWith(serverId: serverId, serverName: serverName);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {'metadata': metadata, 'onDeckEpisode': onDeckEpisode};
|
||||
} catch (e) {
|
||||
// Network failed - try cache as fallback (no OnDeck)
|
||||
appLogger.w(
|
||||
'Network request failed for metadata with OnDeck, trying cache',
|
||||
error: e,
|
||||
);
|
||||
final cached = await _cache.get(serverId, cacheKey);
|
||||
if (cached != null) {
|
||||
final metadata = _parseMetadataWithImagesFromCachedResponse(cached);
|
||||
return {'metadata': metadata, 'onDeckEpisode': null};
|
||||
}
|
||||
rethrow;
|
||||
}
|
||||
return {'metadata': metadata, 'onDeckEpisode': onDeckEpisode};
|
||||
},
|
||||
) ?? {'metadata': null, 'onDeckEpisode': null};
|
||||
}
|
||||
|
||||
/// Get metadata by rating key with images (includes clearLogo)
|
||||
@@ -479,46 +466,23 @@ class PlexClient {
|
||||
// Cache key is always the base endpoint (no query params)
|
||||
final cacheKey = '/library/metadata/$ratingKey';
|
||||
|
||||
// If offline mode, return from cache only
|
||||
if (_offlineMode) {
|
||||
final cached = await _cache.get(serverId, cacheKey);
|
||||
if (cached != null) {
|
||||
return _parseMetadataWithImagesFromCachedResponse(cached);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Online: try network first
|
||||
try {
|
||||
// Always fetch with chapters/markers - they're included in cached response
|
||||
final response = await _dio.get(
|
||||
return _fetchWithCacheFallback<PlexMetadata>(
|
||||
cacheKey: cacheKey,
|
||||
networkCall: () => _dio.get(
|
||||
'/library/metadata/$ratingKey',
|
||||
queryParameters: {'includeChapters': 1, 'includeMarkers': 1},
|
||||
);
|
||||
|
||||
// Cache at base endpoint (single cache entry per item)
|
||||
if (response.data != null) {
|
||||
await _cache.put(serverId, cacheKey, response.data);
|
||||
}
|
||||
|
||||
final metadataJson = _getFirstMetadataJson(response);
|
||||
return metadataJson != null
|
||||
? PlexMetadata.fromJsonWithImages(
|
||||
metadataJson,
|
||||
).copyWith(serverId: serverId, serverName: serverName)
|
||||
: null;
|
||||
} catch (e) {
|
||||
// Network failed - try cache as fallback
|
||||
appLogger.w(
|
||||
'Network request failed for metadata with images, trying cache',
|
||||
error: e,
|
||||
);
|
||||
final cached = await _cache.get(serverId, cacheKey);
|
||||
if (cached != null) {
|
||||
return _parseMetadataWithImagesFromCachedResponse(cached);
|
||||
}
|
||||
rethrow;
|
||||
}
|
||||
),
|
||||
parseCache: (cachedData) =>
|
||||
_parseMetadataWithImagesFromCachedResponse(cachedData),
|
||||
parseResponse: (response) {
|
||||
final metadataJson = _getFirstMetadataJson(response);
|
||||
return metadataJson != null
|
||||
? PlexMetadata.fromJsonWithImages(
|
||||
metadataJson,
|
||||
).copyWith(serverId: serverId, serverName: serverName)
|
||||
: null;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Parse PlexMetadata with images from a cached response
|
||||
@@ -570,6 +534,40 @@ class PlexClient {
|
||||
}
|
||||
}
|
||||
|
||||
/// Generic cache-network-fallback helper for fetching data
|
||||
///
|
||||
/// This method implements the standard pattern used throughout the client:
|
||||
/// 1. If offline mode is enabled, return cached data only
|
||||
/// 2. Otherwise, try network request first
|
||||
/// 3. If network succeeds and cacheResponse is true, cache the response
|
||||
/// 4. If network fails, fall back to cached data
|
||||
/// 5. If no cached data available, rethrow the network error
|
||||
Future<T?> _fetchWithCacheFallback<T>({
|
||||
required String cacheKey,
|
||||
required Future<Response> Function() networkCall,
|
||||
required T? Function(dynamic cachedData) parseCache,
|
||||
required T? Function(Response response) parseResponse,
|
||||
bool cacheResponse = true,
|
||||
}) async {
|
||||
if (_offlineMode) {
|
||||
final cached = await _cache.get(serverId, cacheKey);
|
||||
if (cached != null) return parseCache(cached);
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
final response = await networkCall();
|
||||
if (cacheResponse && response.data != null) {
|
||||
await _cache.put(serverId, cacheKey, response.data);
|
||||
}
|
||||
return parseResponse(response);
|
||||
} catch (e) {
|
||||
appLogger.w('Network request failed for $cacheKey, trying cache', error: e);
|
||||
final cached = await _cache.get(serverId, cacheKey);
|
||||
if (cached != null) return parseCache(cached);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// Get first metadata JSON from response data
|
||||
Map<String, dynamic>? _getFirstMetadataJsonFromData(Map<String, dynamic>? data) {
|
||||
if (data == null) return null;
|
||||
@@ -582,6 +580,35 @@ class PlexClient {
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Wraps an API call that returns a boolean success status
|
||||
Future<bool> _wrapBoolApiCall(
|
||||
Future<Response> Function() apiCall,
|
||||
String errorMessage,
|
||||
) async {
|
||||
try {
|
||||
final response = await apiCall();
|
||||
return response.statusCode == 200;
|
||||
} catch (e) {
|
||||
appLogger.e(errorMessage, error: e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Wraps an API call that returns a list, returning empty list on error
|
||||
Future<List<T>> _wrapListApiCall<T>(
|
||||
Future<Response> Function() apiCall,
|
||||
List<T> Function(Response response) parseResponse,
|
||||
String errorMessage,
|
||||
) async {
|
||||
try {
|
||||
final response = await apiCall();
|
||||
return parseResponse(response);
|
||||
} catch (e) {
|
||||
appLogger.e(errorMessage, error: e);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse audio and subtitle tracks from a stream list
|
||||
({List<PlexAudioTrack> audio, List<PlexSubtitleTrack> subtitles}) _parseStreams(
|
||||
List<dynamic>? streams,
|
||||
@@ -594,7 +621,7 @@ class PlexClient {
|
||||
for (var stream in streams) {
|
||||
final streamType = stream['streamType'] as int?;
|
||||
|
||||
if (streamType == 2) {
|
||||
if (streamType == PlexStreamType.audio) {
|
||||
// Audio track
|
||||
audioTracks.add(
|
||||
PlexAudioTrack(
|
||||
@@ -609,7 +636,7 @@ class PlexClient {
|
||||
selected: stream['selected'] == 1,
|
||||
),
|
||||
);
|
||||
} else if (streamType == 3) {
|
||||
} else if (streamType == PlexStreamType.subtitle) {
|
||||
// Subtitle track
|
||||
subtitleTracks.add(
|
||||
PlexSubtitleTrack(
|
||||
@@ -658,30 +685,26 @@ class PlexClient {
|
||||
String? audioLanguage,
|
||||
String? subtitleLanguage,
|
||||
}) async {
|
||||
try {
|
||||
final queryParams = <String, dynamic>{};
|
||||
if (audioLanguage != null) {
|
||||
queryParams['audioLanguage'] = audioLanguage;
|
||||
}
|
||||
if (subtitleLanguage != null) {
|
||||
queryParams['subtitleLanguage'] = subtitleLanguage;
|
||||
}
|
||||
final queryParams = <String, dynamic>{};
|
||||
if (audioLanguage != null) {
|
||||
queryParams['audioLanguage'] = audioLanguage;
|
||||
}
|
||||
if (subtitleLanguage != null) {
|
||||
queryParams['subtitleLanguage'] = subtitleLanguage;
|
||||
}
|
||||
|
||||
// If no preferences to set, return early
|
||||
if (queryParams.isEmpty) {
|
||||
return true;
|
||||
}
|
||||
// If no preferences to set, return early
|
||||
if (queryParams.isEmpty) {
|
||||
return true;
|
||||
}
|
||||
|
||||
final response = await _dio.put(
|
||||
return _wrapBoolApiCall(
|
||||
() => _dio.put(
|
||||
'/library/metadata/$ratingKey/prefs',
|
||||
queryParameters: queryParams,
|
||||
);
|
||||
|
||||
return response.statusCode == 200;
|
||||
} catch (e) {
|
||||
appLogger.e('Failed to set metadata preferences', error: e);
|
||||
return false;
|
||||
}
|
||||
),
|
||||
'Failed to set metadata preferences',
|
||||
);
|
||||
}
|
||||
|
||||
/// Select specific audio and subtitle streams for playback
|
||||
@@ -693,35 +716,31 @@ class PlexClient {
|
||||
int? subtitleStreamID,
|
||||
bool allParts = true,
|
||||
}) async {
|
||||
try {
|
||||
final queryParams = <String, dynamic>{};
|
||||
if (audioStreamID != null) {
|
||||
queryParams['audioStreamID'] = audioStreamID;
|
||||
final queryParams = <String, dynamic>{};
|
||||
if (audioStreamID != null) {
|
||||
queryParams['audioStreamID'] = audioStreamID;
|
||||
}
|
||||
if (subtitleStreamID != null) {
|
||||
queryParams['subtitleStreamID'] = subtitleStreamID;
|
||||
}
|
||||
if (allParts) {
|
||||
// If no streams to select, return early
|
||||
if (queryParams.isEmpty) {
|
||||
return true;
|
||||
}
|
||||
if (subtitleStreamID != null) {
|
||||
queryParams['subtitleStreamID'] = subtitleStreamID;
|
||||
}
|
||||
if (allParts) {
|
||||
// If no streams to select, return early
|
||||
if (queryParams.isEmpty) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Use PUT request on /library/parts/{partId}
|
||||
final response = await _dio.put(
|
||||
// Use PUT request on /library/parts/{partId}
|
||||
return _wrapBoolApiCall(
|
||||
() => _dio.put(
|
||||
'/library/parts/$partId',
|
||||
queryParameters: queryParams,
|
||||
);
|
||||
|
||||
return response.statusCode == 200;
|
||||
}
|
||||
// Si allParts est false, retourner true ou false explicitement (selon la logique souhaitée)
|
||||
// Ici, on retourne true par défaut si rien n'est fait
|
||||
return true;
|
||||
} catch (e) {
|
||||
appLogger.e('Failed to select streams', error: e);
|
||||
return false;
|
||||
),
|
||||
'Failed to select streams',
|
||||
);
|
||||
}
|
||||
// Si allParts est false, retourner true ou false explicitement (selon la logique souhaitée)
|
||||
// Ici, on retourne true par défaut si rien n'est fait
|
||||
return true;
|
||||
}
|
||||
|
||||
/// Search across all libraries using the hub search endpoint
|
||||
@@ -797,10 +816,7 @@ class PlexClient {
|
||||
final allItems = _extractMetadataList(response);
|
||||
|
||||
// Filter out music content (artists, albums, tracks)
|
||||
return allItems.where((item) {
|
||||
final type = item.type.toLowerCase();
|
||||
return type != 'artist' && type != 'album' && type != 'track';
|
||||
}).toList();
|
||||
return allItems.where((item) => !item.isMusicContent).toList();
|
||||
}
|
||||
|
||||
/// Get on deck items (continue watching, filtered to video content only)
|
||||
@@ -817,10 +833,7 @@ class PlexClient {
|
||||
.toList();
|
||||
|
||||
// Filter out music content (artists, albums, tracks)
|
||||
return allItems.where((item) {
|
||||
final type = item.type.toLowerCase();
|
||||
return type != 'artist' && type != 'album' && type != 'track';
|
||||
}).toList();
|
||||
return allItems.where((item) => !item.isMusicContent).toList();
|
||||
}
|
||||
return [];
|
||||
}
|
||||
@@ -840,37 +853,12 @@ class PlexClient {
|
||||
Future<List<PlexMetadata>> getChildren(String ratingKey) async {
|
||||
final endpoint = '/library/metadata/$ratingKey/children';
|
||||
|
||||
// If offline mode, return from cache only
|
||||
if (_offlineMode) {
|
||||
final cached = await _cache.get(serverId, endpoint);
|
||||
if (cached != null) {
|
||||
return _parseMetadataListFromCachedResponse(cached);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
// Online: try network first
|
||||
try {
|
||||
final response = await _dio.get(endpoint);
|
||||
|
||||
// Cache the successful response
|
||||
if (response.data != null) {
|
||||
await _cache.put(serverId, endpoint, response.data);
|
||||
}
|
||||
|
||||
return _extractMetadataList(response);
|
||||
} catch (e) {
|
||||
// Network failed - try cache as fallback
|
||||
appLogger.w(
|
||||
'Network request failed for children, trying cache',
|
||||
error: e,
|
||||
);
|
||||
final cached = await _cache.get(serverId, endpoint);
|
||||
if (cached != null) {
|
||||
return _parseMetadataListFromCachedResponse(cached);
|
||||
}
|
||||
rethrow;
|
||||
}
|
||||
return await _fetchWithCacheFallback<List<PlexMetadata>>(
|
||||
cacheKey: endpoint,
|
||||
networkCall: () => _dio.get(endpoint),
|
||||
parseCache: (cachedData) => _parseMetadataListFromCachedResponse(cachedData),
|
||||
parseResponse: (response) => _extractMetadataList(response),
|
||||
) ?? [];
|
||||
}
|
||||
|
||||
/// Get all unwatched episodes for a TV show across all seasons
|
||||
@@ -884,12 +872,12 @@ class PlexClient {
|
||||
|
||||
// Get episodes from each season
|
||||
for (final season in seasons) {
|
||||
if (season.type == 'season') {
|
||||
if (season.isSeason) {
|
||||
final episodes = await getChildren(season.ratingKey);
|
||||
|
||||
// Filter for unwatched episodes
|
||||
final unwatchedEpisodes = episodes
|
||||
.where((ep) => ep.type == 'episode' && (ep.viewCount ?? 0) == 0)
|
||||
.where((ep) => ep.isEpisode && (ep.viewCount ?? 0) == 0)
|
||||
.toList();
|
||||
|
||||
allEpisodes.addAll(unwatchedEpisodes);
|
||||
@@ -907,7 +895,7 @@ class PlexClient {
|
||||
|
||||
// Filter for unwatched episodes
|
||||
return episodes
|
||||
.where((ep) => ep.type == 'episode' && (ep.viewCount ?? 0) == 0)
|
||||
.where((ep) => ep.isEpisode && (ep.viewCount ?? 0) == 0)
|
||||
.toList();
|
||||
}
|
||||
|
||||
@@ -1268,9 +1256,9 @@ class PlexClient {
|
||||
|
||||
for (var stream in streams) {
|
||||
final streamType = stream['streamType'] as int?;
|
||||
if (streamType == 1 && videoStream == null) {
|
||||
if (streamType == PlexStreamType.video && videoStream == null) {
|
||||
videoStream = stream;
|
||||
} else if (streamType == 2 && audioStream == null) {
|
||||
} else if (streamType == PlexStreamType.audio && audioStream == null) {
|
||||
audioStream = stream;
|
||||
}
|
||||
}
|
||||
@@ -1488,8 +1476,7 @@ class PlexClient {
|
||||
// Filter out non-video content types and tag with server info
|
||||
final videoItems = hub.items
|
||||
.where((item) {
|
||||
final type = item.type.toLowerCase();
|
||||
return type == 'movie' || type == 'show';
|
||||
return item.isMovie || item.isShow;
|
||||
})
|
||||
.map(
|
||||
(item) => item.copyWith(
|
||||
@@ -1530,31 +1517,27 @@ class PlexClient {
|
||||
/// Get full content from a hub using its hub key
|
||||
/// Returns the complete list of metadata items in the hub
|
||||
Future<List<PlexMetadata>> getHubContent(String hubKey) async {
|
||||
try {
|
||||
final response = await _dio.get(hubKey);
|
||||
final allItems = _extractMetadataList(response);
|
||||
|
||||
// Filter out non-video content types
|
||||
return allItems.where((item) {
|
||||
final type = item.type.toLowerCase();
|
||||
return type == 'movie' || type == 'show';
|
||||
}).toList();
|
||||
} catch (e) {
|
||||
appLogger.e('Failed to get hub content: $e');
|
||||
return [];
|
||||
}
|
||||
return _wrapListApiCall<PlexMetadata>(
|
||||
() => _dio.get(hubKey),
|
||||
(response) {
|
||||
final allItems = _extractMetadataList(response);
|
||||
// Filter out non-video content types
|
||||
return allItems.where((item) {
|
||||
return item.isMovie || item.isShow;
|
||||
}).toList();
|
||||
},
|
||||
'Failed to get hub content',
|
||||
);
|
||||
}
|
||||
|
||||
/// Get playlist content by playlist ID
|
||||
/// Returns the list of metadata items in the playlist
|
||||
Future<List<PlexMetadata>> getPlaylist(String playlistId) async {
|
||||
try {
|
||||
final response = await _dio.get('/playlists/$playlistId/items');
|
||||
return _extractMetadataList(response);
|
||||
} catch (e) {
|
||||
appLogger.e('Failed to get playlist: $e');
|
||||
return [];
|
||||
}
|
||||
return _wrapListApiCall<PlexMetadata>(
|
||||
() => _dio.get('/playlists/$playlistId/items'),
|
||||
_extractMetadataList,
|
||||
'Failed to get playlist',
|
||||
);
|
||||
}
|
||||
|
||||
/// Get all playlists
|
||||
@@ -1564,22 +1547,16 @@ class PlexClient {
|
||||
String playlistType = 'video',
|
||||
bool? smart,
|
||||
}) async {
|
||||
try {
|
||||
final queryParams = <String, dynamic>{'playlistType': playlistType};
|
||||
if (smart != null) {
|
||||
queryParams['smart'] = smart ? '1' : '0';
|
||||
}
|
||||
|
||||
final response = await _dio.get(
|
||||
'/playlists',
|
||||
queryParameters: queryParams,
|
||||
);
|
||||
|
||||
return _extractPlaylistList(response);
|
||||
} catch (e) {
|
||||
appLogger.e('Failed to get playlists: $e');
|
||||
return [];
|
||||
final queryParams = <String, dynamic>{'playlistType': playlistType};
|
||||
if (smart != null) {
|
||||
queryParams['smart'] = smart ? '1' : '0';
|
||||
}
|
||||
|
||||
return _wrapListApiCall<PlexPlaylist>(
|
||||
() => _dio.get('/playlists', queryParameters: queryParams),
|
||||
_extractPlaylistList,
|
||||
'Failed to get playlists',
|
||||
);
|
||||
}
|
||||
|
||||
/// Get playlist metadata by playlist ID
|
||||
@@ -1654,13 +1631,10 @@ class PlexClient {
|
||||
|
||||
/// Delete a playlist
|
||||
Future<bool> deletePlaylist(String playlistId) async {
|
||||
try {
|
||||
await _dio.delete('/playlists/$playlistId');
|
||||
return true;
|
||||
} catch (e) {
|
||||
appLogger.e('Failed to delete playlist: $e');
|
||||
return false;
|
||||
}
|
||||
return _wrapBoolApiCall(
|
||||
() => _dio.delete('/playlists/$playlistId'),
|
||||
'Failed to delete playlist',
|
||||
);
|
||||
}
|
||||
|
||||
/// Add items to a playlist
|
||||
@@ -1670,20 +1644,20 @@ class PlexClient {
|
||||
required String playlistId,
|
||||
required String uri,
|
||||
}) async {
|
||||
try {
|
||||
appLogger.d(
|
||||
'Adding to playlist $playlistId with URI: ${uri.substring(0, uri.length > 100 ? 100 : uri.length)}${uri.length > 100 ? "..." : ""}',
|
||||
);
|
||||
final response = await _dio.put(
|
||||
appLogger.d(
|
||||
'Adding to playlist $playlistId with URI: ${uri.substring(0, uri.length > 100 ? 100 : uri.length)}${uri.length > 100 ? "..." : ""}',
|
||||
);
|
||||
final result = await _wrapBoolApiCall(
|
||||
() => _dio.put(
|
||||
'/playlists/$playlistId/items',
|
||||
queryParameters: {'uri': uri},
|
||||
);
|
||||
appLogger.d('Add to playlist response status: ${response.statusCode}');
|
||||
return response.statusCode == 200;
|
||||
} catch (e) {
|
||||
appLogger.e('Failed to add to playlist', error: e);
|
||||
return false;
|
||||
),
|
||||
'Failed to add to playlist',
|
||||
);
|
||||
if (result) {
|
||||
appLogger.d('Add to playlist response status: 200');
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/// Remove an item from a playlist
|
||||
@@ -1693,13 +1667,10 @@ class PlexClient {
|
||||
required String playlistId,
|
||||
required String playlistItemId,
|
||||
}) async {
|
||||
try {
|
||||
await _dio.delete('/playlists/$playlistId/items/$playlistItemId');
|
||||
return true;
|
||||
} catch (e) {
|
||||
appLogger.e('Failed to remove from playlist: $e');
|
||||
return false;
|
||||
}
|
||||
return _wrapBoolApiCall(
|
||||
() => _dio.delete('/playlists/$playlistId/items/$playlistItemId'),
|
||||
'Failed to remove from playlist',
|
||||
);
|
||||
}
|
||||
|
||||
/// Move a playlist item to a new position
|
||||
@@ -1712,31 +1683,28 @@ class PlexClient {
|
||||
required int playlistItemId,
|
||||
required int afterPlaylistItemId,
|
||||
}) async {
|
||||
try {
|
||||
appLogger.d(
|
||||
'Moving playlist item $playlistItemId after $afterPlaylistItemId in playlist $playlistId',
|
||||
);
|
||||
await _dio.put(
|
||||
appLogger.d(
|
||||
'Moving playlist item $playlistItemId after $afterPlaylistItemId in playlist $playlistId',
|
||||
);
|
||||
final result = await _wrapBoolApiCall(
|
||||
() => _dio.put(
|
||||
'/playlists/$playlistId/items/$playlistItemId/move',
|
||||
queryParameters: {'after': afterPlaylistItemId},
|
||||
);
|
||||
),
|
||||
'Failed to move playlist item',
|
||||
);
|
||||
if (result) {
|
||||
appLogger.d('Successfully moved playlist item');
|
||||
return true;
|
||||
} catch (e) {
|
||||
appLogger.e('Failed to move playlist item', error: e);
|
||||
return false;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/// Clear all items from a playlist
|
||||
Future<bool> clearPlaylist(String playlistId) async {
|
||||
try {
|
||||
await _dio.delete('/playlists/$playlistId/items');
|
||||
return true;
|
||||
} catch (e) {
|
||||
appLogger.e('Failed to clear playlist: $e');
|
||||
return false;
|
||||
}
|
||||
return _wrapBoolApiCall(
|
||||
() => _dio.delete('/playlists/$playlistId/items'),
|
||||
'Failed to clear playlist',
|
||||
);
|
||||
}
|
||||
|
||||
/// Update playlist metadata (e.g., title, summary)
|
||||
@@ -1746,30 +1714,27 @@ class PlexClient {
|
||||
String? title,
|
||||
String? summary,
|
||||
}) async {
|
||||
try {
|
||||
final queryParams = <String, dynamic>{
|
||||
'type': 'playlist',
|
||||
'id': playlistId,
|
||||
};
|
||||
final queryParams = <String, dynamic>{
|
||||
'type': 'playlist',
|
||||
'id': playlistId,
|
||||
};
|
||||
|
||||
if (title != null) {
|
||||
queryParams['title.value'] = title;
|
||||
queryParams['title.locked'] = '1';
|
||||
}
|
||||
if (summary != null) {
|
||||
queryParams['summary.value'] = summary;
|
||||
queryParams['summary.locked'] = '1';
|
||||
}
|
||||
if (title != null) {
|
||||
queryParams['title.value'] = title;
|
||||
queryParams['title.locked'] = '1';
|
||||
}
|
||||
if (summary != null) {
|
||||
queryParams['summary.value'] = summary;
|
||||
queryParams['summary.locked'] = '1';
|
||||
}
|
||||
|
||||
await _dio.put(
|
||||
return _wrapBoolApiCall(
|
||||
() => _dio.put(
|
||||
'/library/metadata/$playlistId',
|
||||
queryParameters: queryParams,
|
||||
);
|
||||
return true;
|
||||
} catch (e) {
|
||||
appLogger.e('Failed to update playlist: $e');
|
||||
return false;
|
||||
}
|
||||
),
|
||||
'Failed to update playlist',
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
@@ -1779,51 +1744,46 @@ class PlexClient {
|
||||
/// Get all collections for a library section
|
||||
/// Returns collections as PlexMetadata objects with type="collection"
|
||||
Future<List<PlexMetadata>> getLibraryCollections(String sectionId) async {
|
||||
try {
|
||||
final response = await _dio.get(
|
||||
return _wrapListApiCall<PlexMetadata>(
|
||||
() => _dio.get(
|
||||
'/library/sections/$sectionId/collections',
|
||||
queryParameters: {'includeGuids': 1},
|
||||
);
|
||||
final allItems = _extractMetadataList(response);
|
||||
|
||||
// Collections should have type="collection"
|
||||
return allItems.where((item) {
|
||||
return item.type.toLowerCase() == 'collection';
|
||||
}).toList();
|
||||
} catch (e) {
|
||||
appLogger.e('Failed to get library collections: $e');
|
||||
return [];
|
||||
}
|
||||
),
|
||||
(response) {
|
||||
final allItems = _extractMetadataList(response);
|
||||
// Collections should have type="collection"
|
||||
return allItems.where((item) {
|
||||
return item.isCollection;
|
||||
}).toList();
|
||||
},
|
||||
'Failed to get library collections',
|
||||
);
|
||||
}
|
||||
|
||||
/// Get items in a collection
|
||||
/// Returns the list of metadata items in the collection
|
||||
Future<List<PlexMetadata>> getCollectionItems(String collectionId) async {
|
||||
try {
|
||||
final response = await _dio.get(
|
||||
'/library/collections/$collectionId/children',
|
||||
);
|
||||
return _extractMetadataList(response);
|
||||
} catch (e) {
|
||||
appLogger.e('Failed to get collection items: $e');
|
||||
return [];
|
||||
}
|
||||
return _wrapListApiCall<PlexMetadata>(
|
||||
() => _dio.get('/library/collections/$collectionId/children'),
|
||||
_extractMetadataList,
|
||||
'Failed to get collection items',
|
||||
);
|
||||
}
|
||||
|
||||
/// Delete a collection
|
||||
/// Deletes a library collection from the server
|
||||
Future<bool> deleteCollection(String sectionId, String collectionId) async {
|
||||
try {
|
||||
appLogger.d(
|
||||
'Deleting collection: sectionId=$sectionId, collectionId=$collectionId',
|
||||
);
|
||||
final response = await _dio.delete('/library/collections/$collectionId');
|
||||
appLogger.d('Delete collection response: ${response.statusCode}');
|
||||
return true;
|
||||
} catch (e) {
|
||||
appLogger.e('Failed to delete collection', error: e);
|
||||
return false;
|
||||
appLogger.d(
|
||||
'Deleting collection: sectionId=$sectionId, collectionId=$collectionId',
|
||||
);
|
||||
final result = await _wrapBoolApiCall(
|
||||
() => _dio.delete('/library/collections/$collectionId'),
|
||||
'Failed to delete collection',
|
||||
);
|
||||
if (result) {
|
||||
appLogger.d('Delete collection response: 200');
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/// Create a new collection
|
||||
@@ -1875,18 +1835,18 @@ class PlexClient {
|
||||
required String collectionId,
|
||||
required String uri,
|
||||
}) async {
|
||||
try {
|
||||
appLogger.d('Adding items to collection: collectionId=$collectionId');
|
||||
final response = await _dio.put(
|
||||
appLogger.d('Adding items to collection: collectionId=$collectionId');
|
||||
final result = await _wrapBoolApiCall(
|
||||
() => _dio.put(
|
||||
'/library/collections/$collectionId/items',
|
||||
queryParameters: {'uri': uri},
|
||||
);
|
||||
appLogger.d('Add to collection response: ${response.statusCode}');
|
||||
return true;
|
||||
} catch (e) {
|
||||
appLogger.e('Failed to add items to collection', error: e);
|
||||
return false;
|
||||
),
|
||||
'Failed to add items to collection',
|
||||
);
|
||||
if (result) {
|
||||
appLogger.d('Add to collection response: 200');
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/// Remove an item from a collection
|
||||
@@ -1895,19 +1855,19 @@ class PlexClient {
|
||||
required String collectionId,
|
||||
required String itemId,
|
||||
}) async {
|
||||
try {
|
||||
appLogger.d(
|
||||
'Removing item from collection: collectionId=$collectionId, itemId=$itemId',
|
||||
);
|
||||
final response = await _dio.delete(
|
||||
appLogger.d(
|
||||
'Removing item from collection: collectionId=$collectionId, itemId=$itemId',
|
||||
);
|
||||
final result = await _wrapBoolApiCall(
|
||||
() => _dio.delete(
|
||||
'/library/collections/$collectionId/items/$itemId',
|
||||
);
|
||||
appLogger.d('Remove from collection response: ${response.statusCode}');
|
||||
return true;
|
||||
} catch (e) {
|
||||
appLogger.e('Failed to remove item from collection', error: e);
|
||||
return false;
|
||||
),
|
||||
'Failed to remove item from collection',
|
||||
);
|
||||
if (result) {
|
||||
appLogger.d('Remove from collection response: 200');
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
@@ -2009,13 +1969,10 @@ class PlexClient {
|
||||
|
||||
/// Clear all items from a play queue
|
||||
Future<bool> clearPlayQueue(int playQueueId) async {
|
||||
try {
|
||||
await _dio.delete('/playQueues/$playQueueId/items');
|
||||
return true;
|
||||
} catch (e) {
|
||||
appLogger.e('Failed to clear play queue: $e');
|
||||
return false;
|
||||
}
|
||||
return _wrapBoolApiCall(
|
||||
() => _dio.delete('/playQueues/$playQueueId/items'),
|
||||
'Failed to clear play queue',
|
||||
);
|
||||
}
|
||||
|
||||
/// Create a play queue for a TV show (all episodes)
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
/// Utility class for content type checking and filtering
|
||||
class ContentTypeHelper {
|
||||
/// Checks if the given type is music content (artist, album, or track)
|
||||
///
|
||||
/// [type] The content type string to check
|
||||
/// Returns true if the type is artist, album, or track (case-insensitive)
|
||||
static bool isMusicContent(String type) {
|
||||
final lowerType = type.toLowerCase();
|
||||
return lowerType == 'artist' ||
|
||||
lowerType == 'album' ||
|
||||
lowerType == 'track';
|
||||
}
|
||||
|
||||
/// Checks if the given library is a music library
|
||||
///
|
||||
/// [lib] The library object to check (must have a 'type' property)
|
||||
/// Returns true if the library type is 'artist' (case-insensitive)
|
||||
static bool isMusicLibrary(dynamic lib) {
|
||||
if (lib == null) return false;
|
||||
try {
|
||||
final type = (lib as dynamic).type as String?;
|
||||
return type?.toLowerCase() == 'artist';
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Checks if the given type is video content (movie, show, episode, or season)
|
||||
///
|
||||
/// [type] The content type string to check
|
||||
/// Returns true if the type is movie, show, episode, or season (case-insensitive)
|
||||
static bool isVideoContent(String type) {
|
||||
final lowerType = type.toLowerCase();
|
||||
return lowerType == 'movie' ||
|
||||
lowerType == 'show' ||
|
||||
lowerType == 'episode' ||
|
||||
lowerType == 'season';
|
||||
}
|
||||
|
||||
/// Filters out music content from a list of items
|
||||
///
|
||||
/// [items] The list of items to filter
|
||||
/// [getType] A function that extracts the type string from each item
|
||||
/// Returns a new list with music content removed
|
||||
///
|
||||
/// Example:
|
||||
/// ```dart
|
||||
/// final filtered = ContentTypeHelper.filterOutMusic(
|
||||
/// items,
|
||||
/// (item) => item.type,
|
||||
/// );
|
||||
/// ```
|
||||
static List<T> filterOutMusic<T>(List<T> items, String Function(T) getType) {
|
||||
return items.where((item) => !isMusicContent(getType(item))).toList();
|
||||
}
|
||||
}
|
||||
@@ -27,10 +27,12 @@ class ScreenBreakpoints {
|
||||
|
||||
/// Whether width is wide tablet (900px - 1199px)
|
||||
/// Useful for layouts that need more columns than phone but less than desktop
|
||||
static bool isWideTablet(double width) => width >= wideTablet && width < desktop;
|
||||
static bool isWideTablet(double width) =>
|
||||
width >= wideTablet && width < desktop;
|
||||
|
||||
/// Whether width is desktop-sized (1200px - 1599px)
|
||||
static bool isDesktop(double width) => width >= desktop && width < largeDesktop;
|
||||
static bool isDesktop(double width) =>
|
||||
width >= desktop && width < largeDesktop;
|
||||
|
||||
/// Whether width is large desktop-sized (>= 1600px)
|
||||
static bool isLargeDesktop(double width) => width >= largeDesktop;
|
||||
@@ -69,3 +71,57 @@ class GridLayoutConstants {
|
||||
/// Standard grid padding
|
||||
static EdgeInsets get gridPadding => const EdgeInsets.fromLTRB(8, 0, 8, 8);
|
||||
}
|
||||
|
||||
/// Layout constants for spacing and corner radius
|
||||
class LayoutConstants {
|
||||
/// Spacing constants
|
||||
static const double spacingXSmall = 4.0;
|
||||
static const double spacingSmall = 8.0;
|
||||
static const double spacingMedium = 12.0;
|
||||
static const double spacingLarge = 16.0;
|
||||
static const double spacingXLarge = 24.0;
|
||||
|
||||
/// Corner radius constants
|
||||
static const double cornerRadiusSmall = 6.0;
|
||||
static const double cornerRadiusMedium = 8.0;
|
||||
static const double cornerRadiusLarge = 12.0;
|
||||
|
||||
/// Convenience getters for EdgeInsets
|
||||
static EdgeInsets get paddingXSmall => const EdgeInsets.all(spacingXSmall);
|
||||
static EdgeInsets get paddingSmall => const EdgeInsets.all(spacingSmall);
|
||||
static EdgeInsets get paddingMedium => const EdgeInsets.all(spacingMedium);
|
||||
static EdgeInsets get paddingLarge => const EdgeInsets.all(spacingLarge);
|
||||
static EdgeInsets get paddingXLarge => const EdgeInsets.all(spacingXLarge);
|
||||
|
||||
/// Convenience getters for horizontal EdgeInsets
|
||||
static EdgeInsets get paddingHorizontalXSmall =>
|
||||
const EdgeInsets.symmetric(horizontal: spacingXSmall);
|
||||
static EdgeInsets get paddingHorizontalSmall =>
|
||||
const EdgeInsets.symmetric(horizontal: spacingSmall);
|
||||
static EdgeInsets get paddingHorizontalMedium =>
|
||||
const EdgeInsets.symmetric(horizontal: spacingMedium);
|
||||
static EdgeInsets get paddingHorizontalLarge =>
|
||||
const EdgeInsets.symmetric(horizontal: spacingLarge);
|
||||
static EdgeInsets get paddingHorizontalXLarge =>
|
||||
const EdgeInsets.symmetric(horizontal: spacingXLarge);
|
||||
|
||||
/// Convenience getters for vertical EdgeInsets
|
||||
static EdgeInsets get paddingVerticalXSmall =>
|
||||
const EdgeInsets.symmetric(vertical: spacingXSmall);
|
||||
static EdgeInsets get paddingVerticalSmall =>
|
||||
const EdgeInsets.symmetric(vertical: spacingSmall);
|
||||
static EdgeInsets get paddingVerticalMedium =>
|
||||
const EdgeInsets.symmetric(vertical: spacingMedium);
|
||||
static EdgeInsets get paddingVerticalLarge =>
|
||||
const EdgeInsets.symmetric(vertical: spacingLarge);
|
||||
static EdgeInsets get paddingVerticalXLarge =>
|
||||
const EdgeInsets.symmetric(vertical: spacingXLarge);
|
||||
|
||||
/// Convenience getters for BorderRadius
|
||||
static BorderRadius get borderRadiusSmall =>
|
||||
BorderRadius.circular(cornerRadiusSmall);
|
||||
static BorderRadius get borderRadiusMedium =>
|
||||
BorderRadius.circular(cornerRadiusMedium);
|
||||
static BorderRadius get borderRadiusLarge =>
|
||||
BorderRadius.circular(cornerRadiusLarge);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Utility functions for showing snackbars throughout the application
|
||||
|
||||
/// Shows a standard snackbar with a message
|
||||
///
|
||||
/// [context] The build context
|
||||
/// [message] The message to display
|
||||
/// [duration] Optional duration, defaults to 3 seconds
|
||||
void showAppSnackBar(
|
||||
BuildContext context,
|
||||
String message, {
|
||||
Duration? duration,
|
||||
}) {
|
||||
if (!context.mounted) return;
|
||||
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(message),
|
||||
duration: duration ?? const Duration(seconds: 3),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Shows an error snackbar with a message
|
||||
///
|
||||
/// [context] The build context
|
||||
/// [message] The error message to display
|
||||
void showErrorSnackBar(BuildContext context, String message) {
|
||||
if (!context.mounted) return;
|
||||
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(message),
|
||||
backgroundColor: Colors.red,
|
||||
duration: const Duration(seconds: 4),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Shows a success snackbar with a message
|
||||
///
|
||||
/// [context] The build context
|
||||
/// [message] The success message to display
|
||||
void showSuccessSnackBar(BuildContext context, String message) {
|
||||
if (!context.mounted) return;
|
||||
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(message),
|
||||
backgroundColor: Colors.green,
|
||||
duration: const Duration(seconds: 3),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -9,6 +9,7 @@ class BottomSheetHeader extends StatelessWidget {
|
||||
final String title;
|
||||
|
||||
/// Optional leading widget (e.g., icon or back button)
|
||||
/// Takes precedence over [icon] and [onBack]
|
||||
final Widget? leading;
|
||||
|
||||
/// Optional action widget (e.g., clear button)
|
||||
@@ -18,35 +19,95 @@ class BottomSheetHeader extends StatelessWidget {
|
||||
/// Defaults to Navigator.pop(context)
|
||||
final VoidCallback? onClose;
|
||||
|
||||
/// Optional icon to display as leading widget
|
||||
/// Only used if [leading] and [onBack] are null
|
||||
final IconData? icon;
|
||||
|
||||
/// Optional color for the icon
|
||||
/// Only used when [icon] is provided
|
||||
final Color? iconColor;
|
||||
|
||||
/// Optional callback for back button
|
||||
/// When provided, displays a back button as the leading widget
|
||||
/// Takes precedence over [icon]
|
||||
final VoidCallback? onBack;
|
||||
|
||||
/// Optional text style for the title
|
||||
final TextStyle? titleStyle;
|
||||
|
||||
/// Optional text color for the title
|
||||
/// Only used if [titleStyle] is null
|
||||
final Color? titleColor;
|
||||
|
||||
/// Whether to show the bottom border
|
||||
/// Defaults to true
|
||||
final bool showBorder;
|
||||
|
||||
const BottomSheetHeader({
|
||||
super.key,
|
||||
required this.title,
|
||||
this.leading,
|
||||
this.action,
|
||||
this.onClose,
|
||||
this.icon,
|
||||
this.iconColor,
|
||||
this.onBack,
|
||||
this.titleStyle,
|
||||
this.titleColor,
|
||||
this.showBorder = true,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Determine the leading widget based on priority: leading > onBack > icon
|
||||
Widget? resolvedLeading;
|
||||
if (leading != null) {
|
||||
resolvedLeading = leading;
|
||||
} else if (onBack != null) {
|
||||
resolvedLeading = IconButton(
|
||||
icon: AppIcon(
|
||||
Symbols.arrow_back_rounded,
|
||||
fill: 1,
|
||||
color: iconColor,
|
||||
),
|
||||
onPressed: onBack,
|
||||
);
|
||||
} else if (icon != null) {
|
||||
resolvedLeading = AppIcon(icon!, fill: 1, color: iconColor);
|
||||
}
|
||||
|
||||
// Determine the title style
|
||||
final effectiveTitleStyle = titleStyle ??
|
||||
TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: titleColor,
|
||||
);
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
border: Border(
|
||||
bottom: BorderSide(color: Theme.of(context).dividerColor),
|
||||
),
|
||||
),
|
||||
decoration: showBorder
|
||||
? BoxDecoration(
|
||||
border: Border(
|
||||
bottom: BorderSide(color: Theme.of(context).dividerColor),
|
||||
),
|
||||
)
|
||||
: null,
|
||||
child: Row(
|
||||
children: [
|
||||
if (leading != null) ...[leading!, const SizedBox(width: 8)],
|
||||
if (resolvedLeading != null) ...[
|
||||
resolvedLeading,
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
Expanded(
|
||||
child: Text(
|
||||
title,
|
||||
style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
|
||||
style: effectiveTitleStyle,
|
||||
),
|
||||
),
|
||||
if (action != null) action!,
|
||||
IconButton(
|
||||
icon: const AppIcon(Symbols.close_rounded, fill: 1),
|
||||
icon: AppIcon(Symbols.close_rounded, fill: 1, color: iconColor),
|
||||
onPressed: onClose ?? () => Navigator.pop(context),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -5,6 +5,7 @@ import '../i18n/strings.g.dart';
|
||||
import '../models/download_progress.dart';
|
||||
import '../models/download_status.dart';
|
||||
import '../models/plex_metadata.dart';
|
||||
import '../models/plex_metadata_extensions.dart';
|
||||
|
||||
/// Represents a node in the download tree
|
||||
class DownloadTreeNode {
|
||||
@@ -111,12 +112,12 @@ class _DownloadTreeViewState extends State<DownloadTreeView> {
|
||||
|
||||
if (meta == null) continue;
|
||||
|
||||
if (meta.type.toLowerCase() == 'episode') {
|
||||
if (meta.isEpisode) {
|
||||
// Group episodes by show
|
||||
final showKey = meta.grandparentRatingKey ?? 'unknown';
|
||||
showGroups.putIfAbsent(showKey, () => []);
|
||||
showGroups[showKey]!.add(entry);
|
||||
} else if (meta.type.toLowerCase() == 'movie') {
|
||||
} else if (meta.isMovie) {
|
||||
// Movies go at top level
|
||||
movies.add(
|
||||
DownloadTreeNode(
|
||||
|
||||
@@ -14,9 +14,11 @@ import '../utils/provider_extensions.dart';
|
||||
import '../utils/content_rating_formatter.dart';
|
||||
import '../utils/duration_formatter.dart';
|
||||
import '../utils/media_navigation_helper.dart';
|
||||
import '../utils/snackbar_helper.dart';
|
||||
import '../theme/theme_helper.dart';
|
||||
import '../i18n/strings.g.dart';
|
||||
import 'media_context_menu.dart';
|
||||
import 'media_progress_bar.dart';
|
||||
import 'plex_optimized_image.dart';
|
||||
|
||||
class MediaCard extends StatefulWidget {
|
||||
@@ -128,12 +130,7 @@ class MediaCardState extends State<MediaCard> {
|
||||
|
||||
switch (result) {
|
||||
case MediaNavigationResult.unsupported:
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(t.messages.musicNotSupported),
|
||||
duration: const Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
showAppSnackBar(context, t.messages.musicNotSupported);
|
||||
case MediaNavigationResult.listRefreshNeeded:
|
||||
widget.onListRefresh?.call();
|
||||
case MediaNavigationResult.navigated:
|
||||
@@ -802,13 +799,9 @@ class _MediaCardHelpers {
|
||||
bottomLeft: Radius.circular(8),
|
||||
bottomRight: Radius.circular(8),
|
||||
),
|
||||
child: LinearProgressIndicator(
|
||||
value: metadata.viewOffset! / metadata.duration!,
|
||||
backgroundColor: tokens(context).outline,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(
|
||||
Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
minHeight: 4,
|
||||
child: MediaProgressBar(
|
||||
viewOffset: metadata.viewOffset!,
|
||||
duration: metadata.duration!,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -14,6 +14,7 @@ import '../providers/offline_watch_provider.dart';
|
||||
import '../utils/provider_extensions.dart';
|
||||
import '../utils/app_logger.dart';
|
||||
import '../utils/library_refresh_notifier.dart';
|
||||
import '../utils/snackbar_helper.dart';
|
||||
import '../screens/media_detail_screen.dart';
|
||||
import '../screens/season_detail_screen.dart';
|
||||
import '../utils/smart_deletion_handler.dart';
|
||||
@@ -379,9 +380,7 @@ class MediaContextMenuState extends State<MediaContextMenu> {
|
||||
ratingKey: metadata.ratingKey,
|
||||
);
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(t.messages.markedAsWatchedOffline)),
|
||||
);
|
||||
showAppSnackBar(context, t.messages.markedAsWatchedOffline);
|
||||
widget.onRefresh?.call(metadata.ratingKey);
|
||||
}
|
||||
} else {
|
||||
@@ -402,9 +401,7 @@ class MediaContextMenuState extends State<MediaContextMenu> {
|
||||
ratingKey: metadata.ratingKey,
|
||||
);
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(t.messages.markedAsUnwatchedOffline)),
|
||||
);
|
||||
showAppSnackBar(context, t.messages.markedAsUnwatchedOffline);
|
||||
widget.onRefresh?.call(metadata.ratingKey);
|
||||
}
|
||||
} else {
|
||||
@@ -423,8 +420,9 @@ class MediaContextMenuState extends State<MediaContextMenu> {
|
||||
try {
|
||||
await client.removeFromOnDeck(metadata!.ratingKey);
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(t.messages.removedFromContinueWatching)),
|
||||
showSuccessSnackBar(
|
||||
context,
|
||||
t.messages.removedFromContinueWatching,
|
||||
);
|
||||
// Use specific callback if provided, otherwise fallback to onRefresh
|
||||
if (widget.onRemoveFromContinueWatching != null) {
|
||||
@@ -435,10 +433,9 @@ class MediaContextMenuState extends State<MediaContextMenu> {
|
||||
}
|
||||
} catch (e) {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(t.messages.errorLoading(error: e.toString())),
|
||||
),
|
||||
showErrorSnackBar(
|
||||
context,
|
||||
t.messages.errorLoading(error: e.toString()),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -512,15 +509,14 @@ class MediaContextMenuState extends State<MediaContextMenu> {
|
||||
try {
|
||||
await action();
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(successMessage)));
|
||||
showSuccessSnackBar(context, successMessage);
|
||||
widget.onRefresh?.call(widget.item.ratingKey);
|
||||
}
|
||||
} catch (e) {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(t.messages.errorLoading(error: e.toString()))),
|
||||
showErrorSnackBar(
|
||||
context,
|
||||
t.messages.errorLoading(error: e.toString()),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -548,9 +544,7 @@ class MediaContextMenuState extends State<MediaContextMenu> {
|
||||
}
|
||||
} catch (e) {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text('$errorPrefix: $e')));
|
||||
showErrorSnackBar(context, '$errorPrefix: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -589,9 +583,7 @@ class MediaContextMenuState extends State<MediaContextMenu> {
|
||||
FileInfoBottomSheet(fileInfo: fileInfo, title: metadata.title),
|
||||
);
|
||||
} else if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(t.messages.fileInfoNotAvailable)),
|
||||
);
|
||||
showErrorSnackBar(context, t.messages.fileInfoNotAvailable);
|
||||
}
|
||||
} catch (e) {
|
||||
// Close loading indicator if it's still open
|
||||
@@ -600,10 +592,9 @@ class MediaContextMenuState extends State<MediaContextMenu> {
|
||||
}
|
||||
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(t.messages.errorLoadingFileInfo(error: e.toString())),
|
||||
),
|
||||
showErrorSnackBar(
|
||||
context,
|
||||
t.messages.errorLoadingFileInfo(error: e.toString()),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -761,16 +752,12 @@ class MediaContextMenuState extends State<MediaContextMenu> {
|
||||
if (context.mounted) {
|
||||
if (newPlaylist != null) {
|
||||
appLogger.d('Successfully created playlist: ${newPlaylist.title}');
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(t.playlists.created)));
|
||||
showSuccessSnackBar(context, t.playlists.created);
|
||||
// Trigger refresh of playlists tab
|
||||
LibraryRefreshNotifier().notifyPlaylistsChanged();
|
||||
} else {
|
||||
appLogger.e('Failed to create playlist - API returned null');
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(t.playlists.errorCreating)));
|
||||
showErrorSnackBar(context, t.playlists.errorCreating);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@@ -786,18 +773,14 @@ class MediaContextMenuState extends State<MediaContextMenu> {
|
||||
if (context.mounted) {
|
||||
if (success) {
|
||||
appLogger.d('Successfully added item(s) to playlist $result');
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(t.playlists.itemAdded)));
|
||||
showSuccessSnackBar(context, t.playlists.itemAdded);
|
||||
// Trigger refresh of playlists tab
|
||||
LibraryRefreshNotifier().notifyPlaylistsChanged();
|
||||
} else {
|
||||
appLogger.e(
|
||||
'Failed to add item(s) to playlist $result - API returned false',
|
||||
);
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(t.playlists.errorAdding)));
|
||||
showErrorSnackBar(context, t.playlists.errorAdding);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -808,11 +791,9 @@ class MediaContextMenuState extends State<MediaContextMenu> {
|
||||
stackTrace: stackTrace,
|
||||
);
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('${t.playlists.errorLoading}: ${e.toString()}'),
|
||||
duration: const Duration(seconds: 5),
|
||||
),
|
||||
showErrorSnackBar(
|
||||
context,
|
||||
'${t.playlists.errorLoading}: ${e.toString()}',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -880,12 +861,9 @@ class MediaContextMenuState extends State<MediaContextMenu> {
|
||||
|
||||
if (sectionId == null) {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text(
|
||||
'Unable to determine library section for this item',
|
||||
),
|
||||
),
|
||||
showErrorSnackBar(
|
||||
context,
|
||||
'Unable to determine library section for this item',
|
||||
);
|
||||
}
|
||||
return;
|
||||
@@ -975,22 +953,19 @@ class MediaContextMenuState extends State<MediaContextMenu> {
|
||||
|
||||
if (addSuccess) {
|
||||
appLogger.d('Successfully added item to new collection');
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(t.collections.created)));
|
||||
showSuccessSnackBar(context, t.collections.created);
|
||||
// Trigger refresh of collections tab
|
||||
LibraryRefreshNotifier().notifyCollectionsChanged();
|
||||
} else {
|
||||
appLogger.e('Failed to add item to new collection');
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(t.collections.errorAddingToCollection)),
|
||||
showErrorSnackBar(
|
||||
context,
|
||||
t.collections.errorAddingToCollection,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
appLogger.e('Failed to create collection - API returned null');
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(t.collections.errorAddingToCollection)),
|
||||
);
|
||||
showErrorSnackBar(context, t.collections.errorAddingToCollection);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@@ -1006,18 +981,14 @@ class MediaContextMenuState extends State<MediaContextMenu> {
|
||||
if (context.mounted) {
|
||||
if (success) {
|
||||
appLogger.d('Successfully added item(s) to collection $result');
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(t.collections.addedToCollection)),
|
||||
);
|
||||
showSuccessSnackBar(context, t.collections.addedToCollection);
|
||||
// Trigger refresh of collections tab
|
||||
LibraryRefreshNotifier().notifyCollectionsChanged();
|
||||
} else {
|
||||
appLogger.e(
|
||||
'Failed to add item(s) to collection $result - API returned false',
|
||||
);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(t.collections.errorAddingToCollection)),
|
||||
);
|
||||
showErrorSnackBar(context, t.collections.errorAddingToCollection);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1028,13 +999,9 @@ class MediaContextMenuState extends State<MediaContextMenu> {
|
||||
stackTrace: stackTrace,
|
||||
);
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
'${t.collections.errorAddingToCollection}: ${e.toString()}',
|
||||
),
|
||||
duration: const Duration(seconds: 5),
|
||||
),
|
||||
showErrorSnackBar(
|
||||
context,
|
||||
'${t.collections.errorAddingToCollection}: ${e.toString()}',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1087,28 +1054,24 @@ class MediaContextMenuState extends State<MediaContextMenu> {
|
||||
|
||||
if (context.mounted) {
|
||||
if (success) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(t.collections.removedFromCollection)),
|
||||
);
|
||||
showSuccessSnackBar(context, t.collections.removedFromCollection);
|
||||
// Trigger refresh of collections tab
|
||||
LibraryRefreshNotifier().notifyCollectionsChanged();
|
||||
// Trigger list refresh to remove the item from the view
|
||||
widget.onListRefresh?.call();
|
||||
} else {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(t.collections.removeFromCollectionFailed)),
|
||||
showErrorSnackBar(
|
||||
context,
|
||||
t.collections.removeFromCollectionFailed,
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
appLogger.e('Failed to remove from collection', error: e);
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
t.collections.removeFromCollectionError(error: e.toString()),
|
||||
),
|
||||
),
|
||||
showErrorSnackBar(
|
||||
context,
|
||||
t.collections.removeFromCollectionError(error: e.toString()),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1213,38 +1176,29 @@ class MediaContextMenuState extends State<MediaContextMenu> {
|
||||
|
||||
if (context.mounted) {
|
||||
if (success) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
isCollection ? t.collections.deleted : t.playlists.deleted,
|
||||
),
|
||||
),
|
||||
showSuccessSnackBar(
|
||||
context,
|
||||
isCollection ? t.collections.deleted : t.playlists.deleted,
|
||||
);
|
||||
// Trigger list refresh
|
||||
widget.onListRefresh?.call();
|
||||
} else {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
isCollection
|
||||
? t.collections.deleteFailed
|
||||
: t.playlists.errorDeleting,
|
||||
),
|
||||
),
|
||||
showErrorSnackBar(
|
||||
context,
|
||||
isCollection
|
||||
? t.collections.deleteFailed
|
||||
: t.playlists.errorDeleting,
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
appLogger.e('Failed to delete $itemTypeLabel', error: e);
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
isCollection
|
||||
? t.collections.deleteFailedWithError(error: e.toString())
|
||||
: t.playlists.errorDeleting,
|
||||
),
|
||||
),
|
||||
showErrorSnackBar(
|
||||
context,
|
||||
isCollection
|
||||
? t.collections.deleteFailedWithError(error: e.toString())
|
||||
: t.playlists.errorDeleting,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1266,21 +1220,18 @@ class MediaContextMenuState extends State<MediaContextMenu> {
|
||||
final message = count > 1
|
||||
? t.downloads.episodesQueued(count: count)
|
||||
: t.downloads.downloadQueued;
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(message)));
|
||||
showSuccessSnackBar(context, message);
|
||||
}
|
||||
} on CellularDownloadBlockedException {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(t.settings.cellularDownloadBlocked)),
|
||||
);
|
||||
showErrorSnackBar(context, t.settings.cellularDownloadBlocked);
|
||||
}
|
||||
} catch (e) {
|
||||
appLogger.e('Failed to queue download', error: e);
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(t.messages.errorLoading(error: e.toString()))),
|
||||
showErrorSnackBar(
|
||||
context,
|
||||
t.messages.errorLoading(error: e.toString()),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1326,17 +1277,16 @@ class MediaContextMenuState extends State<MediaContextMenu> {
|
||||
);
|
||||
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(t.downloads.downloadDeleted)));
|
||||
showSuccessSnackBar(context, t.downloads.downloadDeleted);
|
||||
// Refresh the view if needed
|
||||
widget.onRefresh?.call(metadata.ratingKey);
|
||||
}
|
||||
} catch (e) {
|
||||
appLogger.e('Failed to delete download', error: e);
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(t.messages.errorLoading(error: e.toString()))),
|
||||
showErrorSnackBar(
|
||||
context,
|
||||
t.messages.errorLoading(error: e.toString()),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Reusable media progress bar widget for displaying watch progress
|
||||
///
|
||||
/// Shows a linear progress indicator based on viewOffset and duration.
|
||||
/// Uses theme defaults when colors are not provided.
|
||||
class MediaProgressBar extends StatelessWidget {
|
||||
final int viewOffset; // Progress position in milliseconds
|
||||
final int duration; // Total duration in milliseconds
|
||||
final Color? backgroundColor;
|
||||
final Color? valueColor;
|
||||
final double? minHeight;
|
||||
|
||||
const MediaProgressBar({
|
||||
super.key,
|
||||
required this.viewOffset,
|
||||
required this.duration,
|
||||
this.backgroundColor,
|
||||
this.valueColor,
|
||||
this.minHeight,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Calculate progress value (0.0 to 1.0)
|
||||
final progress = duration > 0 ? viewOffset / duration : 0.0;
|
||||
|
||||
return LinearProgressIndicator(
|
||||
value: progress.clamp(0.0, 1.0),
|
||||
backgroundColor: backgroundColor ??
|
||||
Theme.of(context).colorScheme.surfaceContainerHighest,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(
|
||||
valueColor ?? Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
minHeight: minHeight ?? 4,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import '../providers/multi_server_provider.dart';
|
||||
import '../services/fullscreen_state_manager.dart';
|
||||
import '../services/storage_service.dart';
|
||||
import '../theme/theme_helper.dart';
|
||||
import '../utils/content_type_helper.dart';
|
||||
import '../i18n/strings.g.dart';
|
||||
|
||||
/// Tracks focus state for a set of named items, avoiding repeated boilerplate
|
||||
@@ -231,7 +232,7 @@ class SideNavigationRailState extends State<SideNavigationRail> {
|
||||
await provider.aggregationService.getLibrariesFromAllServers();
|
||||
|
||||
// Filter out unsupported library types (music)
|
||||
var filtered = libraries.where((lib) => lib.type != 'artist').toList();
|
||||
var filtered = libraries.where((lib) => !ContentTypeHelper.isMusicLibrary(lib)).toList();
|
||||
|
||||
// Apply saved order
|
||||
final savedOrder = storage.getLibraryOrder();
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:plezy/widgets/app_icon.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import 'package:plezy/widgets/bottom_sheet_header.dart';
|
||||
|
||||
/// Shared header widget for video control sheets
|
||||
///
|
||||
/// This is now a thin wrapper around [BottomSheetHeader] for backward compatibility.
|
||||
/// Consider using [BottomSheetHeader] directly for new implementations.
|
||||
///
|
||||
/// Provides a consistent header with an icon/back button, title, and close button
|
||||
class VideoSheetHeader extends StatelessWidget {
|
||||
final String title;
|
||||
@@ -23,42 +25,18 @@ class VideoSheetHeader extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Row(
|
||||
children: [
|
||||
// Back button or icon
|
||||
if (onBack != null)
|
||||
IconButton(
|
||||
icon: const AppIcon(
|
||||
Symbols.arrow_back_rounded,
|
||||
fill: 1,
|
||||
color: Colors.white,
|
||||
),
|
||||
onPressed: onBack,
|
||||
)
|
||||
else if (icon != null)
|
||||
AppIcon(icon, fill: 1, color: iconColor ?? Colors.white),
|
||||
const SizedBox(width: 12),
|
||||
Text(
|
||||
title,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
IconButton(
|
||||
icon: const AppIcon(
|
||||
Symbols.close_rounded,
|
||||
fill: 1,
|
||||
color: Colors.white,
|
||||
),
|
||||
onPressed: onClose ?? () => Navigator.pop(context),
|
||||
),
|
||||
],
|
||||
return BottomSheetHeader(
|
||||
title: title,
|
||||
icon: icon,
|
||||
iconColor: iconColor ?? Colors.white,
|
||||
onBack: onBack,
|
||||
onClose: onClose,
|
||||
titleStyle: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
showBorder: false,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ import '../../services/settings_service.dart';
|
||||
import '../../utils/platform_detector.dart';
|
||||
import '../../utils/player_utils.dart';
|
||||
import '../../utils/provider_extensions.dart';
|
||||
import '../../utils/snackbar_helper.dart';
|
||||
import '../../utils/video_control_icons.dart';
|
||||
import '../../utils/app_logger.dart';
|
||||
import '../../i18n/strings.g.dart';
|
||||
@@ -1377,9 +1378,7 @@ class _PlexVideoControlsState extends State<PlexVideoControls>
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(t.messages.errorLoading(error: e.toString()))),
|
||||
);
|
||||
showErrorSnackBar(context, t.messages.errorLoading(error: e.toString()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import 'package:material_symbols_icons/symbols.dart';
|
||||
import '../../../mpv/mpv.dart';
|
||||
import '../../../services/sleep_timer_service.dart';
|
||||
import '../../../utils/duration_formatter.dart';
|
||||
import '../../../utils/snackbar_helper.dart';
|
||||
import '../../../i18n/strings.g.dart';
|
||||
|
||||
/// Widget displaying list of sleep timer durations for selection
|
||||
@@ -58,23 +59,13 @@ class SleepTimerDurationList extends StatelessWidget {
|
||||
|
||||
// Show a snackbar notification
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(t.videoControls.sleepTimerCompleted),
|
||||
duration: const Duration(seconds: 3),
|
||||
),
|
||||
);
|
||||
showSuccessSnackBar(context, t.videoControls.sleepTimerCompleted);
|
||||
}
|
||||
});
|
||||
Navigator.pop(context);
|
||||
|
||||
// Show confirmation snackbar
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(t.messages.sleepTimerSet(label: label)),
|
||||
duration: const Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
showSuccessSnackBar(context, t.messages.sleepTimerSet(label: label));
|
||||
},
|
||||
);
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user