lint: enforce unawaited_futures, prefer_final_locals, etc.
This commit is contained in:
@@ -7,6 +7,16 @@ analyzer:
|
|||||||
plugins:
|
plugins:
|
||||||
- dart_code_linter
|
- dart_code_linter
|
||||||
|
|
||||||
|
linter:
|
||||||
|
rules:
|
||||||
|
unawaited_futures: true
|
||||||
|
cancel_subscriptions: true
|
||||||
|
close_sinks: true
|
||||||
|
use_super_parameters: true
|
||||||
|
prefer_final_locals: true
|
||||||
|
prefer_final_in_for_each: true
|
||||||
|
avoid_print: true
|
||||||
|
|
||||||
dart_code_linter:
|
dart_code_linter:
|
||||||
extends:
|
extends:
|
||||||
- package:dart_code_linter/presets/recommended.yaml
|
- package:dart_code_linter/presets/recommended.yaml
|
||||||
|
|||||||
+524
-1461
File diff suppressed because it is too large
Load Diff
+22
-15
@@ -2,6 +2,7 @@ import 'dart:async';
|
|||||||
import 'dart:io' show Platform, ProcessInfo;
|
import 'dart:io' show Platform, ProcessInfo;
|
||||||
import 'dart:ui' show AppExitResponse;
|
import 'dart:ui' show AppExitResponse;
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
|
// ignore: depend_on_referenced_packages
|
||||||
import 'package:shared_preferences_foundation/shared_preferences_foundation.dart';
|
import 'package:shared_preferences_foundation/shared_preferences_foundation.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/gestures.dart';
|
import 'package:flutter/gestures.dart';
|
||||||
@@ -138,7 +139,7 @@ Future<void> _bootstrapApp() async {
|
|||||||
final savedLocale = settings.read(SettingsService.appLocale);
|
final savedLocale = settings.read(SettingsService.appLocale);
|
||||||
|
|
||||||
// Initialize localization with saved locale
|
// Initialize localization with saved locale
|
||||||
LocaleSettings.setLocale(savedLocale);
|
unawaited(LocaleSettings.setLocale(savedLocale));
|
||||||
|
|
||||||
// Needed for formatting dates in different locales
|
// Needed for formatting dates in different locales
|
||||||
await initializeDateFormatting(savedLocale.languageCode, null);
|
await initializeDateFormatting(savedLocale.languageCode, null);
|
||||||
@@ -212,7 +213,7 @@ Future<void> _bootstrapApp() async {
|
|||||||
|
|
||||||
// Desktop-only services
|
// Desktop-only services
|
||||||
if (PlatformDetector.isDesktopOS()) {
|
if (PlatformDetector.isDesktopOS()) {
|
||||||
DiscordRPCService.instance.initialize();
|
unawaited(DiscordRPCService.instance.initialize());
|
||||||
}
|
}
|
||||||
|
|
||||||
// Trakt scrobble service (all platforms)
|
// Trakt scrobble service (all platforms)
|
||||||
@@ -251,7 +252,7 @@ FutureOr<SentryEvent?> _beforeSend(SentryEvent event, Hint _) {
|
|||||||
if (instance != null && !instance.read(SettingsService.crashReporting)) return null;
|
if (instance != null && !instance.read(SettingsService.crashReporting)) return null;
|
||||||
|
|
||||||
// Drop unactionable errors
|
// Drop unactionable errors
|
||||||
var exceptions = event.exceptions;
|
final exceptions = event.exceptions;
|
||||||
if (exceptions != null) {
|
if (exceptions != null) {
|
||||||
bool shouldDrop(SentryException e) {
|
bool shouldDrop(SentryException e) {
|
||||||
final v = e.value;
|
final v = e.value;
|
||||||
@@ -917,7 +918,7 @@ class _SetupScreenState extends State<SetupScreen> {
|
|||||||
// Check network connectivity early to fast-path airplane mode.
|
// Check network connectivity early to fast-path airplane mode.
|
||||||
// Timeout guards against connectivity_plus hanging on some Android TV devices after force-close.
|
// Timeout guards against connectivity_plus hanging on some Android TV devices after force-close.
|
||||||
bool hasNetwork;
|
bool hasNetwork;
|
||||||
Sentry.addBreadcrumb(Breadcrumb(message: 'Checking network connectivity', category: 'setup'));
|
unawaited(Sentry.addBreadcrumb(Breadcrumb(message: 'Checking network connectivity', category: 'setup')));
|
||||||
try {
|
try {
|
||||||
final connectivityResult = await Connectivity().checkConnectivity().timeout(
|
final connectivityResult = await Connectivity().checkConnectivity().timeout(
|
||||||
const Duration(seconds: 3),
|
const Duration(seconds: 3),
|
||||||
@@ -929,7 +930,9 @@ class _SetupScreenState extends State<SetupScreen> {
|
|||||||
hasNetwork = true;
|
hasNetwork = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
Sentry.addBreadcrumb(Breadcrumb(message: 'Network check done: hasNetwork=$hasNetwork', category: 'setup'));
|
unawaited(
|
||||||
|
Sentry.addBreadcrumb(Breadcrumb(message: 'Network check done: hasNetwork=$hasNetwork', category: 'setup')),
|
||||||
|
);
|
||||||
|
|
||||||
if (hasNetwork) {
|
if (hasNetwork) {
|
||||||
_setStatus(t.common.refreshingServers);
|
_setStatus(t.common.refreshingServers);
|
||||||
@@ -941,7 +944,7 @@ class _SetupScreenState extends State<SetupScreen> {
|
|||||||
if (refreshResult == ServerRefreshResult.authError) {
|
if (refreshResult == ServerRefreshResult.authError) {
|
||||||
await storage.clearCredentials();
|
await storage.clearCredentials();
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
Navigator.pushReplacement(context, fadeRoute(const AuthScreen()));
|
unawaited(Navigator.pushReplacement(context, fadeRoute(const AuthScreen())));
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -954,7 +957,7 @@ class _SetupScreenState extends State<SetupScreen> {
|
|||||||
|
|
||||||
if (servers.isEmpty) {
|
if (servers.isEmpty) {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
Navigator.pushReplacement(context, fadeRoute(const AuthScreen()));
|
unawaited(Navigator.pushReplacement(context, fadeRoute(const AuthScreen())));
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -966,11 +969,13 @@ class _SetupScreenState extends State<SetupScreen> {
|
|||||||
_setStatus(t.common.startingOfflineMode);
|
_setStatus(t.common.startingOfflineMode);
|
||||||
await context.read<DownloadProvider>().ensureInitialized();
|
await context.read<DownloadProvider>().ensureInitialized();
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
Navigator.pushReplacement(context, fadeRoute(const MainScreen(isOfflineMode: true)));
|
unawaited(Navigator.pushReplacement(context, fadeRoute(const MainScreen(isOfflineMode: true))));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
Sentry.addBreadcrumb(Breadcrumb(message: 'Connecting to ${servers.length} server(s)', category: 'setup'));
|
unawaited(
|
||||||
|
Sentry.addBreadcrumb(Breadcrumb(message: 'Connecting to ${servers.length} server(s)', category: 'setup')),
|
||||||
|
);
|
||||||
_setStatus(t.common.connectingToServers);
|
_setStatus(t.common.connectingToServers);
|
||||||
|
|
||||||
// Populate per-server status for splash display
|
// Populate per-server status for splash display
|
||||||
@@ -1006,16 +1011,18 @@ class _SetupScreenState extends State<SetupScreen> {
|
|||||||
if (result.hasConnections && result.firstClient != null) {
|
if (result.hasConnections && result.firstClient != null) {
|
||||||
// Resume any downloads that were interrupted by app kill
|
// Resume any downloads that were interrupted by app kill
|
||||||
final downloadProvider = context.read<DownloadProvider>();
|
final downloadProvider = context.read<DownloadProvider>();
|
||||||
downloadProvider.ensureInitialized().then((_) {
|
unawaited(
|
||||||
downloadProvider.resumeQueuedDownloads(result.firstClient!);
|
downloadProvider.ensureInitialized().then((_) {
|
||||||
});
|
downloadProvider.resumeQueuedDownloads(result.firstClient!);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
Navigator.pushReplacement(context, fadeRoute(MainScreen(client: result.firstClient!)));
|
unawaited(Navigator.pushReplacement(context, fadeRoute(MainScreen(client: result.firstClient!))));
|
||||||
} else {
|
} else {
|
||||||
_setStatus(t.common.startingOfflineMode);
|
_setStatus(t.common.startingOfflineMode);
|
||||||
await context.read<DownloadProvider>().ensureInitialized();
|
await context.read<DownloadProvider>().ensureInitialized();
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
Navigator.pushReplacement(context, fadeRoute(const MainScreen(isOfflineMode: true)));
|
unawaited(Navigator.pushReplacement(context, fadeRoute(const MainScreen(isOfflineMode: true))));
|
||||||
}
|
}
|
||||||
} catch (e, stackTrace) {
|
} catch (e, stackTrace) {
|
||||||
appLogger.e('Error during multi-server connection', error: e, stackTrace: stackTrace);
|
appLogger.e('Error during multi-server connection', error: e, stackTrace: stackTrace);
|
||||||
@@ -1024,7 +1031,7 @@ class _SetupScreenState extends State<SetupScreen> {
|
|||||||
_setStatus(t.common.startingOfflineMode);
|
_setStatus(t.common.startingOfflineMode);
|
||||||
await context.read<DownloadProvider>().ensureInitialized();
|
await context.read<DownloadProvider>().ensureInitialized();
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
Navigator.pushReplacement(context, fadeRoute(const MainScreen(isOfflineMode: true)));
|
unawaited(Navigator.pushReplacement(context, fadeRoute(const MainScreen(isOfflineMode: true))));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,62 +10,44 @@ RemoteDevice _$RemoteDeviceFromJson(Map<String, dynamic> json) => RemoteDevice(
|
|||||||
id: json['id'] as String,
|
id: json['id'] as String,
|
||||||
name: json['name'] as String,
|
name: json['name'] as String,
|
||||||
platform: json['platform'] as String,
|
platform: json['platform'] as String,
|
||||||
connectedAt: json['connectedAt'] == null
|
connectedAt: json['connectedAt'] == null ? null : DateTime.parse(json['connectedAt'] as String),
|
||||||
? null
|
capabilities: (json['capabilities'] as Map<String, dynamic>?)?.map((k, e) => MapEntry(k, e as bool)),
|
||||||
: DateTime.parse(json['connectedAt'] as String),
|
|
||||||
capabilities: (json['capabilities'] as Map<String, dynamic>?)?.map(
|
|
||||||
(k, e) => MapEntry(k, e as bool),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
Map<String, dynamic> _$RemoteDeviceToJson(RemoteDevice instance) =>
|
Map<String, dynamic> _$RemoteDeviceToJson(RemoteDevice instance) => <String, dynamic>{
|
||||||
<String, dynamic>{
|
'id': instance.id,
|
||||||
'id': instance.id,
|
'name': instance.name,
|
||||||
'name': instance.name,
|
'platform': instance.platform,
|
||||||
'platform': instance.platform,
|
'connectedAt': instance.connectedAt.toIso8601String(),
|
||||||
'connectedAt': instance.connectedAt.toIso8601String(),
|
'capabilities': instance.capabilities,
|
||||||
'capabilities': instance.capabilities,
|
|
||||||
};
|
|
||||||
|
|
||||||
RemoteSession _$RemoteSessionFromJson(Map<String, dynamic> json) =>
|
|
||||||
RemoteSession(
|
|
||||||
role: $enumDecode(
|
|
||||||
_$RemoteSessionRoleEnumMap,
|
|
||||||
json['role'],
|
|
||||||
unknownValue: RemoteSessionRole.remote,
|
|
||||||
),
|
|
||||||
status:
|
|
||||||
$enumDecodeNullable(
|
|
||||||
_$RemoteSessionStatusEnumMap,
|
|
||||||
json['status'],
|
|
||||||
unknownValue: RemoteSessionStatus.disconnected,
|
|
||||||
) ??
|
|
||||||
RemoteSessionStatus.disconnected,
|
|
||||||
connectedDevice: json['connectedDevice'] == null
|
|
||||||
? null
|
|
||||||
: RemoteDevice.fromJson(
|
|
||||||
json['connectedDevice'] as Map<String, dynamic>,
|
|
||||||
),
|
|
||||||
createdAt: json['createdAt'] == null
|
|
||||||
? null
|
|
||||||
: DateTime.parse(json['createdAt'] as String),
|
|
||||||
errorMessage: json['errorMessage'] as String?,
|
|
||||||
);
|
|
||||||
|
|
||||||
Map<String, dynamic> _$RemoteSessionToJson(RemoteSession instance) =>
|
|
||||||
<String, dynamic>{
|
|
||||||
'role': _$RemoteSessionRoleEnumMap[instance.role]!,
|
|
||||||
'status': _$RemoteSessionStatusEnumMap[instance.status]!,
|
|
||||||
'connectedDevice': instance.connectedDevice,
|
|
||||||
'createdAt': instance.createdAt.toIso8601String(),
|
|
||||||
'errorMessage': instance.errorMessage,
|
|
||||||
};
|
|
||||||
|
|
||||||
const _$RemoteSessionRoleEnumMap = {
|
|
||||||
RemoteSessionRole.host: 'host',
|
|
||||||
RemoteSessionRole.remote: 'remote',
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
RemoteSession _$RemoteSessionFromJson(Map<String, dynamic> json) => RemoteSession(
|
||||||
|
role: $enumDecode(_$RemoteSessionRoleEnumMap, json['role'], unknownValue: RemoteSessionRole.remote),
|
||||||
|
status:
|
||||||
|
$enumDecodeNullable(
|
||||||
|
_$RemoteSessionStatusEnumMap,
|
||||||
|
json['status'],
|
||||||
|
unknownValue: RemoteSessionStatus.disconnected,
|
||||||
|
) ??
|
||||||
|
RemoteSessionStatus.disconnected,
|
||||||
|
connectedDevice: json['connectedDevice'] == null
|
||||||
|
? null
|
||||||
|
: RemoteDevice.fromJson(json['connectedDevice'] as Map<String, dynamic>),
|
||||||
|
createdAt: json['createdAt'] == null ? null : DateTime.parse(json['createdAt'] as String),
|
||||||
|
errorMessage: json['errorMessage'] as String?,
|
||||||
|
);
|
||||||
|
|
||||||
|
Map<String, dynamic> _$RemoteSessionToJson(RemoteSession instance) => <String, dynamic>{
|
||||||
|
'role': _$RemoteSessionRoleEnumMap[instance.role]!,
|
||||||
|
'status': _$RemoteSessionStatusEnumMap[instance.status]!,
|
||||||
|
'connectedDevice': instance.connectedDevice,
|
||||||
|
'createdAt': instance.createdAt.toIso8601String(),
|
||||||
|
'errorMessage': instance.errorMessage,
|
||||||
|
};
|
||||||
|
|
||||||
|
const _$RemoteSessionRoleEnumMap = {RemoteSessionRole.host: 'host', RemoteSessionRole.remote: 'remote'};
|
||||||
|
|
||||||
const _$RemoteSessionStatusEnumMap = {
|
const _$RemoteSessionStatusEnumMap = {
|
||||||
RemoteSessionStatus.disconnected: 'disconnected',
|
RemoteSessionStatus.disconnected: 'disconnected',
|
||||||
RemoteSessionStatus.connecting: 'connecting',
|
RemoteSessionStatus.connecting: 'connecting',
|
||||||
|
|||||||
@@ -6,21 +6,15 @@ part of 'play_queue_response.dart';
|
|||||||
// JsonSerializableGenerator
|
// JsonSerializableGenerator
|
||||||
// **************************************************************************
|
// **************************************************************************
|
||||||
|
|
||||||
PlayQueueResponse _$PlayQueueResponseFromJson(Map<String, dynamic> json) =>
|
PlayQueueResponse _$PlayQueueResponseFromJson(Map<String, dynamic> json) => PlayQueueResponse(
|
||||||
PlayQueueResponse(
|
playQueueID: (json['playQueueID'] as num).toInt(),
|
||||||
playQueueID: (json['playQueueID'] as num).toInt(),
|
playQueueSelectedItemID: (json['playQueueSelectedItemID'] as num?)?.toInt(),
|
||||||
playQueueSelectedItemID: (json['playQueueSelectedItemID'] as num?)
|
playQueueSelectedItemOffset: (json['playQueueSelectedItemOffset'] as num?)?.toInt(),
|
||||||
?.toInt(),
|
playQueueSelectedMetadataItemID: json['playQueueSelectedMetadataItemID'] as String?,
|
||||||
playQueueSelectedItemOffset: (json['playQueueSelectedItemOffset'] as num?)
|
playQueueShuffled: flexibleBool(json['playQueueShuffled']),
|
||||||
?.toInt(),
|
playQueueSourceURI: json['playQueueSourceURI'] as String?,
|
||||||
playQueueSelectedMetadataItemID:
|
playQueueTotalCount: (json['playQueueTotalCount'] as num?)?.toInt(),
|
||||||
json['playQueueSelectedMetadataItemID'] as String?,
|
playQueueVersion: (json['playQueueVersion'] as num).toInt(),
|
||||||
playQueueShuffled: flexibleBool(json['playQueueShuffled']),
|
size: (json['size'] as num?)?.toInt(),
|
||||||
playQueueSourceURI: json['playQueueSourceURI'] as String?,
|
items: (json['Metadata'] as List<dynamic>?)?.map((e) => PlexMetadata.fromJson(e as Map<String, dynamic>)).toList(),
|
||||||
playQueueTotalCount: (json['playQueueTotalCount'] as num?)?.toInt(),
|
);
|
||||||
playQueueVersion: (json['playQueueVersion'] as num).toInt(),
|
|
||||||
size: (json['size'] as num?)?.toInt(),
|
|
||||||
items: (json['Metadata'] as List<dynamic>?)
|
|
||||||
?.map((e) => PlexMetadata.fromJson(e as Map<String, dynamic>))
|
|
||||||
.toList(),
|
|
||||||
);
|
|
||||||
|
|||||||
@@ -13,11 +13,7 @@ PlexHome _$PlexHomeFromJson(Map<String, dynamic> json) => PlexHome(
|
|||||||
guestUserUUID: json['guestUserUUID'] as String? ?? '',
|
guestUserUUID: json['guestUserUUID'] as String? ?? '',
|
||||||
guestEnabled: json['guestEnabled'] as bool? ?? false,
|
guestEnabled: json['guestEnabled'] as bool? ?? false,
|
||||||
subscription: json['subscription'] as bool? ?? false,
|
subscription: json['subscription'] as bool? ?? false,
|
||||||
users:
|
users: (json['users'] as List<dynamic>?)?.map((e) => PlexHomeUser.fromJson(e as Map<String, dynamic>)).toList() ?? [],
|
||||||
(json['users'] as List<dynamic>?)
|
|
||||||
?.map((e) => PlexHomeUser.fromJson(e as Map<String, dynamic>))
|
|
||||||
.toList() ??
|
|
||||||
[],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
Map<String, dynamic> _$PlexHomeToJson(PlexHome instance) => <String, dynamic>{
|
Map<String, dynamic> _$PlexHomeToJson(PlexHome instance) => <String, dynamic>{
|
||||||
|
|||||||
@@ -22,19 +22,18 @@ PlexHomeUser _$PlexHomeUserFromJson(Map<String, dynamic> json) => PlexHomeUser(
|
|||||||
protected: json['protected'] as bool? ?? false,
|
protected: json['protected'] as bool? ?? false,
|
||||||
);
|
);
|
||||||
|
|
||||||
Map<String, dynamic> _$PlexHomeUserToJson(PlexHomeUser instance) =>
|
Map<String, dynamic> _$PlexHomeUserToJson(PlexHomeUser instance) => <String, dynamic>{
|
||||||
<String, dynamic>{
|
'id': instance.id,
|
||||||
'id': instance.id,
|
'uuid': instance.uuid,
|
||||||
'uuid': instance.uuid,
|
'title': instance.title,
|
||||||
'title': instance.title,
|
'username': instance.username,
|
||||||
'username': instance.username,
|
'email': instance.email,
|
||||||
'email': instance.email,
|
'friendlyName': instance.friendlyName,
|
||||||
'friendlyName': instance.friendlyName,
|
'thumb': instance.thumb,
|
||||||
'thumb': instance.thumb,
|
'hasPassword': instance.hasPassword,
|
||||||
'hasPassword': instance.hasPassword,
|
'restricted': instance.restricted,
|
||||||
'restricted': instance.restricted,
|
'updatedAt': instance.updatedAt,
|
||||||
'updatedAt': instance.updatedAt,
|
'admin': instance.admin,
|
||||||
'admin': instance.admin,
|
'guest': instance.guest,
|
||||||
'guest': instance.guest,
|
'protected': instance.protected,
|
||||||
'protected': instance.protected,
|
};
|
||||||
};
|
|
||||||
|
|||||||
@@ -19,16 +19,15 @@ PlexLibrary _$PlexLibraryFromJson(Map<String, dynamic> json) => PlexLibrary(
|
|||||||
hidden: (json['hidden'] as num?)?.toInt(),
|
hidden: (json['hidden'] as num?)?.toInt(),
|
||||||
);
|
);
|
||||||
|
|
||||||
Map<String, dynamic> _$PlexLibraryToJson(PlexLibrary instance) =>
|
Map<String, dynamic> _$PlexLibraryToJson(PlexLibrary instance) => <String, dynamic>{
|
||||||
<String, dynamic>{
|
'key': instance.key,
|
||||||
'key': instance.key,
|
'title': instance.title,
|
||||||
'title': instance.title,
|
'type': instance.type,
|
||||||
'type': instance.type,
|
'agent': instance.agent,
|
||||||
'agent': instance.agent,
|
'scanner': instance.scanner,
|
||||||
'scanner': instance.scanner,
|
'language': instance.language,
|
||||||
'language': instance.language,
|
'uuid': instance.uuid,
|
||||||
'uuid': instance.uuid,
|
'updatedAt': instance.updatedAt,
|
||||||
'updatedAt': instance.updatedAt,
|
'createdAt': instance.createdAt,
|
||||||
'createdAt': instance.createdAt,
|
'hidden': instance.hidden,
|
||||||
'hidden': instance.hidden,
|
};
|
||||||
};
|
|
||||||
|
|||||||
@@ -413,7 +413,7 @@ class PlexMetadata with MultiServerFields {
|
|||||||
final images = json['Image'] as List?;
|
final images = json['Image'] as List?;
|
||||||
if (images == null) return null;
|
if (images == null) return null;
|
||||||
|
|
||||||
for (var image in images) {
|
for (final image in images) {
|
||||||
if (image is Map && image['type'] == imageType) {
|
if (image is Map && image['type'] == imageType) {
|
||||||
return image['url'] as String?;
|
return image['url'] as String?;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,9 +42,7 @@ PlexMetadata _$PlexMetadataFromJson(Map<String, dynamic> json) => PlexMetadata(
|
|||||||
leafCount: (json['leafCount'] as num?)?.toInt(),
|
leafCount: (json['leafCount'] as num?)?.toInt(),
|
||||||
viewedLeafCount: (json['viewedLeafCount'] as num?)?.toInt(),
|
viewedLeafCount: (json['viewedLeafCount'] as num?)?.toInt(),
|
||||||
childCount: flexibleInt(json['childCount']),
|
childCount: flexibleInt(json['childCount']),
|
||||||
role: (json['Role'] as List<dynamic>?)
|
role: (json['Role'] as List<dynamic>?)?.map((e) => PlexRole.fromJson(e as Map<String, dynamic>)).toList(),
|
||||||
?.map((e) => PlexRole.fromJson(e as Map<String, dynamic>))
|
|
||||||
.toList(),
|
|
||||||
mediaVersions: (json['Media'] as List<dynamic>?)
|
mediaVersions: (json['Media'] as List<dynamic>?)
|
||||||
?.map((e) => PlexMediaVersion.fromJson(e as Map<String, dynamic>))
|
?.map((e) => PlexMediaVersion.fromJson(e as Map<String, dynamic>))
|
||||||
.toList(),
|
.toList(),
|
||||||
@@ -76,59 +74,58 @@ PlexMetadata _$PlexMetadataFromJson(Map<String, dynamic> json) => PlexMetadata(
|
|||||||
backgroundSquare: json['backgroundSquare'] as String?,
|
backgroundSquare: json['backgroundSquare'] as String?,
|
||||||
);
|
);
|
||||||
|
|
||||||
Map<String, dynamic> _$PlexMetadataToJson(PlexMetadata instance) =>
|
Map<String, dynamic> _$PlexMetadataToJson(PlexMetadata instance) => <String, dynamic>{
|
||||||
<String, dynamic>{
|
'ratingKey': instance.ratingKey,
|
||||||
'ratingKey': instance.ratingKey,
|
'key': instance.key,
|
||||||
'key': instance.key,
|
'guid': instance.guid,
|
||||||
'guid': instance.guid,
|
'studio': instance.studio,
|
||||||
'studio': instance.studio,
|
'type': instance.type,
|
||||||
'type': instance.type,
|
'title': instance.title,
|
||||||
'title': instance.title,
|
'titleSort': instance.titleSort,
|
||||||
'titleSort': instance.titleSort,
|
'contentRating': instance.contentRating,
|
||||||
'contentRating': instance.contentRating,
|
'summary': instance.summary,
|
||||||
'summary': instance.summary,
|
'rating': instance.rating,
|
||||||
'rating': instance.rating,
|
'audienceRating': instance.audienceRating,
|
||||||
'audienceRating': instance.audienceRating,
|
'userRating': instance.userRating,
|
||||||
'userRating': instance.userRating,
|
'year': instance.year,
|
||||||
'year': instance.year,
|
'originallyAvailableAt': instance.originallyAvailableAt,
|
||||||
'originallyAvailableAt': instance.originallyAvailableAt,
|
'thumb': instance.thumb,
|
||||||
'thumb': instance.thumb,
|
'art': instance.art,
|
||||||
'art': instance.art,
|
'duration': instance.duration,
|
||||||
'duration': instance.duration,
|
'addedAt': instance.addedAt,
|
||||||
'addedAt': instance.addedAt,
|
'updatedAt': instance.updatedAt,
|
||||||
'updatedAt': instance.updatedAt,
|
'lastViewedAt': instance.lastViewedAt,
|
||||||
'lastViewedAt': instance.lastViewedAt,
|
'grandparentTitle': instance.grandparentTitle,
|
||||||
'grandparentTitle': instance.grandparentTitle,
|
'grandparentThumb': instance.grandparentThumb,
|
||||||
'grandparentThumb': instance.grandparentThumb,
|
'grandparentArt': instance.grandparentArt,
|
||||||
'grandparentArt': instance.grandparentArt,
|
'grandparentRatingKey': instance.grandparentRatingKey,
|
||||||
'grandparentRatingKey': instance.grandparentRatingKey,
|
'parentTitle': instance.parentTitle,
|
||||||
'parentTitle': instance.parentTitle,
|
'parentThumb': instance.parentThumb,
|
||||||
'parentThumb': instance.parentThumb,
|
'parentRatingKey': instance.parentRatingKey,
|
||||||
'parentRatingKey': instance.parentRatingKey,
|
'parentIndex': instance.parentIndex,
|
||||||
'parentIndex': instance.parentIndex,
|
'index': instance.index,
|
||||||
'index': instance.index,
|
'grandparentTheme': instance.grandparentTheme,
|
||||||
'grandparentTheme': instance.grandparentTheme,
|
'viewOffset': instance.viewOffset,
|
||||||
'viewOffset': instance.viewOffset,
|
'viewCount': instance.viewCount,
|
||||||
'viewCount': instance.viewCount,
|
'leafCount': instance.leafCount,
|
||||||
'leafCount': instance.leafCount,
|
'viewedLeafCount': instance.viewedLeafCount,
|
||||||
'viewedLeafCount': instance.viewedLeafCount,
|
'childCount': instance.childCount,
|
||||||
'childCount': instance.childCount,
|
'Role': instance.role,
|
||||||
'Role': instance.role,
|
'audioLanguage': instance.audioLanguage,
|
||||||
'audioLanguage': instance.audioLanguage,
|
'subtitleLanguage': instance.subtitleLanguage,
|
||||||
'subtitleLanguage': instance.subtitleLanguage,
|
'subtitleMode': instance.subtitleMode,
|
||||||
'subtitleMode': instance.subtitleMode,
|
'playlistItemID': instance.playlistItemID,
|
||||||
'playlistItemID': instance.playlistItemID,
|
'playQueueItemID': instance.playQueueItemID,
|
||||||
'playQueueItemID': instance.playQueueItemID,
|
'librarySectionID': instance.librarySectionID,
|
||||||
'librarySectionID': instance.librarySectionID,
|
'librarySectionTitle': instance.librarySectionTitle,
|
||||||
'librarySectionTitle': instance.librarySectionTitle,
|
'ratingImage': instance.ratingImage,
|
||||||
'ratingImage': instance.ratingImage,
|
'audienceRatingImage': instance.audienceRatingImage,
|
||||||
'audienceRatingImage': instance.audienceRatingImage,
|
'tagline': instance.tagline,
|
||||||
'tagline': instance.tagline,
|
'originalTitle': instance.originalTitle,
|
||||||
'originalTitle': instance.originalTitle,
|
'editionTitle': instance.editionTitle,
|
||||||
'editionTitle': instance.editionTitle,
|
'subtype': instance.subtype,
|
||||||
'subtype': instance.subtype,
|
'extraType': instance.extraType,
|
||||||
'extraType': instance.extraType,
|
'primaryExtraKey': instance.primaryExtraKey,
|
||||||
'primaryExtraKey': instance.primaryExtraKey,
|
'clearLogo': instance.clearLogo,
|
||||||
'clearLogo': instance.clearLogo,
|
'backgroundSquare': instance.backgroundSquare,
|
||||||
'backgroundSquare': instance.backgroundSquare,
|
};
|
||||||
};
|
|
||||||
|
|||||||
@@ -26,23 +26,22 @@ PlexPlaylist _$PlexPlaylistFromJson(Map<String, dynamic> json) => PlexPlaylist(
|
|||||||
thumb: json['thumb'] as String?,
|
thumb: json['thumb'] as String?,
|
||||||
);
|
);
|
||||||
|
|
||||||
Map<String, dynamic> _$PlexPlaylistToJson(PlexPlaylist instance) =>
|
Map<String, dynamic> _$PlexPlaylistToJson(PlexPlaylist instance) => <String, dynamic>{
|
||||||
<String, dynamic>{
|
'ratingKey': instance.ratingKey,
|
||||||
'ratingKey': instance.ratingKey,
|
'key': instance.key,
|
||||||
'key': instance.key,
|
'type': instance.type,
|
||||||
'type': instance.type,
|
'title': instance.title,
|
||||||
'title': instance.title,
|
'summary': instance.summary,
|
||||||
'summary': instance.summary,
|
'smart': instance.smart,
|
||||||
'smart': instance.smart,
|
'playlistType': instance.playlistType,
|
||||||
'playlistType': instance.playlistType,
|
'duration': instance.duration,
|
||||||
'duration': instance.duration,
|
'leafCount': instance.leafCount,
|
||||||
'leafCount': instance.leafCount,
|
'composite': instance.composite,
|
||||||
'composite': instance.composite,
|
'addedAt': instance.addedAt,
|
||||||
'addedAt': instance.addedAt,
|
'updatedAt': instance.updatedAt,
|
||||||
'updatedAt': instance.updatedAt,
|
'lastViewedAt': instance.lastViewedAt,
|
||||||
'lastViewedAt': instance.lastViewedAt,
|
'viewCount': instance.viewCount,
|
||||||
'viewCount': instance.viewCount,
|
'content': instance.content,
|
||||||
'content': instance.content,
|
'guid': instance.guid,
|
||||||
'guid': instance.guid,
|
'thumb': instance.thumb,
|
||||||
'thumb': instance.thumb,
|
};
|
||||||
};
|
|
||||||
|
|||||||
@@ -351,7 +351,7 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player {
|
|||||||
3 => 'quit',
|
3 => 'quit',
|
||||||
4 => 'error',
|
4 => 'error',
|
||||||
5 => 'redirect',
|
5 => 'redirect',
|
||||||
String s => s,
|
final String s => s,
|
||||||
_ => null,
|
_ => null,
|
||||||
};
|
};
|
||||||
if (reason == 'eof') {
|
if (reason == 'eof') {
|
||||||
|
|||||||
@@ -188,7 +188,7 @@ class CompanionRemoteProvider with ChangeNotifier {
|
|||||||
/// Stop the host server and LAN broadcasting.
|
/// Stop the host server and LAN broadcasting.
|
||||||
Future<void> stopHostServer() async {
|
Future<void> stopHostServer() async {
|
||||||
_intentionalDisconnect = true;
|
_intentionalDisconnect = true;
|
||||||
_discoveryService?.stopBroadcasting();
|
await _discoveryService?.stopBroadcasting();
|
||||||
|
|
||||||
if (_peerService != null) {
|
if (_peerService != null) {
|
||||||
await _peerService!.disconnect();
|
await _peerService!.disconnect();
|
||||||
|
|||||||
@@ -126,7 +126,7 @@ class LibrariesProvider extends ChangeNotifier {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Create a map for quick lookup
|
// Create a map for quick lookup
|
||||||
final libraryMap = {for (var lib in libraries) lib.globalKey: lib};
|
final libraryMap = {for (final lib in libraries) lib.globalKey: lib};
|
||||||
|
|
||||||
// Build ordered list based on saved order
|
// Build ordered list based on saved order
|
||||||
final orderedLibraries = <PlexLibrary>[];
|
final orderedLibraries = <PlexLibrary>[];
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ class _AuthScreenState extends State<AuthScreen> {
|
|||||||
setState(() {
|
setState(() {
|
||||||
_useQrFlow = true;
|
_useQrFlow = true;
|
||||||
});
|
});
|
||||||
_startAuthentication();
|
unawaited(_startAuthentication());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -115,7 +115,7 @@ class _AuthScreenState extends State<AuthScreen> {
|
|||||||
await profileFuture;
|
await profileFuture;
|
||||||
|
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
Navigator.pushReplacement(context, fadeRoute(MainScreen(client: result.firstClient!)));
|
unawaited(Navigator.pushReplacement(context, fadeRoute(MainScreen(client: result.firstClient!))));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
appLogger.e('Failed to connect to servers', error: e);
|
appLogger.e('Failed to connect to servers', error: e);
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
|
|||||||
@@ -416,8 +416,8 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
|||||||
|
|
||||||
// Center the active dot when possible
|
// Center the active dot when possible
|
||||||
final center = _currentHeroIndex;
|
final center = _currentHeroIndex;
|
||||||
int start = (center - 2).clamp(0, totalDots - 5);
|
final int start = (center - 2).clamp(0, totalDots - 5);
|
||||||
int end = start + 4; // 5 dots total (0-4 inclusive)
|
final int end = start + 4; // 5 dots total (0-4 inclusive)
|
||||||
|
|
||||||
return (start: start, end: end);
|
return (start: start, end: end);
|
||||||
}
|
}
|
||||||
@@ -461,6 +461,7 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
|||||||
// Get hidden libraries for filtering
|
// Get hidden libraries for filtering
|
||||||
final hiddenLibrariesProvider = Provider.of<HiddenLibrariesProvider>(context, listen: false);
|
final hiddenLibrariesProvider = Provider.of<HiddenLibrariesProvider>(context, listen: false);
|
||||||
await hiddenLibrariesProvider.ensureInitialized();
|
await hiddenLibrariesProvider.ensureInitialized();
|
||||||
|
if (!mounted) return;
|
||||||
_lastSeenHiddenKeys = Set.of(hiddenLibrariesProvider.hiddenLibraryKeys);
|
_lastSeenHiddenKeys = Set.of(hiddenLibrariesProvider.hiddenLibraryKeys);
|
||||||
|
|
||||||
// Get settings for hub mode preference (ensure initialized before accessing)
|
// Get settings for hub mode preference (ensure initialized before accessing)
|
||||||
@@ -509,7 +510,7 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
|||||||
|
|
||||||
// Sync to Android TV Watch Next row
|
// Sync to Android TV Watch Next row
|
||||||
if (Platform.isAndroid) {
|
if (Platform.isAndroid) {
|
||||||
_syncWatchNext(onDeck);
|
unawaited(_syncWatchNext(onDeck));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sync PageController to first page after OnDeck loads
|
// Sync PageController to first page after OnDeck loads
|
||||||
@@ -622,7 +623,7 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
|||||||
|
|
||||||
// Sync to Android TV Watch Next row
|
// Sync to Android TV Watch Next row
|
||||||
if (Platform.isAndroid) {
|
if (Platform.isAndroid) {
|
||||||
_syncWatchNext(onDeck);
|
unawaited(_syncWatchNext(onDeck));
|
||||||
}
|
}
|
||||||
|
|
||||||
appLogger.d('Continue Watching refreshed successfully');
|
appLogger.d('Continue Watching refreshed successfully');
|
||||||
@@ -806,9 +807,11 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
|||||||
playbackStateProvider.clearShuffle();
|
playbackStateProvider.clearShuffle();
|
||||||
|
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
Navigator.of(
|
unawaited(
|
||||||
context,
|
Navigator.of(
|
||||||
).pushAndRemoveUntil(MaterialPageRoute(builder: (context) => const AuthScreen()), (route) => false);
|
context,
|
||||||
|
).pushAndRemoveUntil(MaterialPageRoute(builder: (context) => const AuthScreen()), (route) => false),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:plezy/widgets/app_icon.dart';
|
import 'package:plezy/widgets/app_icon.dart';
|
||||||
@@ -174,7 +176,7 @@ class _LibrariesScreenState extends State<LibrariesScreen>
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (libraryGlobalKeyToLoad != null && mounted) {
|
if (libraryGlobalKeyToLoad != null && mounted) {
|
||||||
_loadLibraryContent(libraryGlobalKeyToLoad);
|
unawaited(_loadLibraryContent(libraryGlobalKeyToLoad));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -536,7 +538,7 @@ class _LibrariesScreenState extends State<LibrariesScreen>
|
|||||||
.toList();
|
.toList();
|
||||||
|
|
||||||
if (visibleLibraries.isNotEmpty) {
|
if (visibleLibraries.isNotEmpty) {
|
||||||
_loadLibraryContent(visibleLibraries.first.globalKey);
|
unawaited(_loadLibraryContent(visibleLibraries.first.globalKey));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -600,16 +602,16 @@ class _LibrariesScreenState extends State<LibrariesScreen>
|
|||||||
|
|
||||||
switch (action) {
|
switch (action) {
|
||||||
case 'scan':
|
case 'scan':
|
||||||
_scanLibrary(library);
|
unawaited(_scanLibrary(library));
|
||||||
break;
|
break;
|
||||||
case 'analyze':
|
case 'analyze':
|
||||||
_analyzeLibrary(library);
|
unawaited(_analyzeLibrary(library));
|
||||||
break;
|
break;
|
||||||
case 'refresh':
|
case 'refresh':
|
||||||
_refreshLibraryMetadata(library);
|
unawaited(_refreshLibraryMetadata(library));
|
||||||
break;
|
break;
|
||||||
case 'empty_trash':
|
case 'empty_trash':
|
||||||
_emptyLibraryTrash(library);
|
unawaited(_emptyLibraryTrash(library));
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -611,8 +611,8 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<PlexMetadata, LibraryBr
|
|||||||
final storage = await StorageService.getInstance();
|
final storage = await StorageService.getInstance();
|
||||||
await storage.saveLibraryFilters(filters, sectionId: widget.library.globalKey);
|
await storage.saveLibraryFilters(filters, sectionId: widget.library.globalKey);
|
||||||
|
|
||||||
_loadItems();
|
unawaited(_loadItems());
|
||||||
_loadFirstCharacters();
|
unawaited(_loadFirstCharacters());
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:material_symbols_icons/symbols.dart';
|
import 'package:material_symbols_icons/symbols.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
@@ -188,7 +190,7 @@ class _LiveTvScreenState extends State<LiveTvScreen>
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Load favorites from the first available server (favorites are cloud-synced)
|
// Load favorites from the first available server (favorites are cloud-synced)
|
||||||
_loadFavorites(multiServer);
|
unawaited(_loadFavorites(multiServer));
|
||||||
|
|
||||||
if (allChannels.isNotEmpty && PlatformDetector.shouldUseSideNavigation(context)) {
|
if (allChannels.isNotEmpty && PlatformDetector.shouldUseSideNavigation(context)) {
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
|||||||
@@ -175,7 +175,7 @@ class _MainScreenState extends State<MainScreen> with RouteAware, WindowListener
|
|||||||
|
|
||||||
// Auto-start companion remote server now that home data is available
|
// Auto-start companion remote server now that home data is available
|
||||||
if (_companionRemoteSetup && mounted) {
|
if (_companionRemoteSetup && mounted) {
|
||||||
_autoStartCompanionRemoteServer(context.read<CompanionRemoteProvider>());
|
unawaited(_autoStartCompanionRemoteServer(context.read<CompanionRemoteProvider>()));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ensure first login (or any unset profile state) requires explicit selection.
|
// Ensure first login (or any unset profile state) requires explicit selection.
|
||||||
@@ -189,7 +189,7 @@ class _MainScreenState extends State<MainScreen> with RouteAware, WindowListener
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check for updates on startup
|
// Check for updates on startup
|
||||||
_checkForUpdatesOnStartup();
|
unawaited(_checkForUpdatesOnStartup());
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -327,7 +327,7 @@ class _MainScreenState extends State<MainScreen> with RouteAware, WindowListener
|
|||||||
final contentId = await watchNext.getInitialDeepLink();
|
final contentId = await watchNext.getInitialDeepLink();
|
||||||
if (contentId != null && mounted) {
|
if (contentId != null && mounted) {
|
||||||
appLogger.d('Watch Next initial deep link: $contentId');
|
appLogger.d('Watch Next initial deep link: $contentId');
|
||||||
_handleWatchNextContentId(contentId);
|
unawaited(_handleWatchNextContentId(contentId));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -357,7 +357,7 @@ class _MainScreenState extends State<MainScreen> with RouteAware, WindowListener
|
|||||||
|
|
||||||
if (metadata == null || !mounted) return;
|
if (metadata == null || !mounted) return;
|
||||||
|
|
||||||
navigateToVideoPlayer(context, metadata: metadata);
|
unawaited(navigateToVideoPlayer(context, metadata: metadata));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
appLogger.e('Watch Next: failed to navigate to media', error: e);
|
appLogger.e('Watch Next: failed to navigate to media', error: e);
|
||||||
}
|
}
|
||||||
@@ -697,7 +697,7 @@ class _MainScreenState extends State<MainScreen> with RouteAware, WindowListener
|
|||||||
if (!result.confirmed) return;
|
if (!result.confirmed) return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
SystemNavigator.pop();
|
unawaited(SystemNavigator.pop());
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -823,7 +823,7 @@ class _MainScreenState extends State<MainScreen> with RouteAware, WindowListener
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Reset other provider states
|
// Reset other provider states
|
||||||
hiddenLibrariesProvider.refresh();
|
unawaited(hiddenLibrariesProvider.refresh());
|
||||||
playbackStateProvider.clearShuffle();
|
playbackStateProvider.clearShuffle();
|
||||||
|
|
||||||
appLogger.d('Cleared all provider states for profile switch');
|
appLogger.d('Cleared all provider states for profile switch');
|
||||||
|
|||||||
@@ -323,7 +323,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
|
|||||||
});
|
});
|
||||||
// Re-fetch episodes for the currently selected season
|
// Re-fetch episodes for the currently selected season
|
||||||
if (!_showEpisodesDirectly && _seasons.isNotEmpty) {
|
if (!_showEpisodesDirectly && _seasons.isNotEmpty) {
|
||||||
_fetchSeasonEpisodes(_selectedSeasonIndex);
|
unawaited(_fetchSeasonEpisodes(_selectedSeasonIndex));
|
||||||
}
|
}
|
||||||
} else if (widget.metadata.isSeason) {
|
} else if (widget.metadata.isSeason) {
|
||||||
await _fetchAllEpisodes();
|
await _fetchAllEpisodes();
|
||||||
@@ -872,8 +872,8 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
|
|||||||
context,
|
context,
|
||||||
isWatched ? t.messages.markedAsUnwatchedOffline : t.messages.markedAsWatchedOffline,
|
isWatched ? t.messages.markedAsUnwatchedOffline : t.messages.markedAsWatchedOffline,
|
||||||
);
|
);
|
||||||
_updateWatchStateOffline();
|
unawaited(_updateWatchStateOffline());
|
||||||
_loadOfflineOnDeckEpisode();
|
unawaited(_loadOfflineOnDeckEpisode());
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Online mode: send to server
|
// Online mode: send to server
|
||||||
@@ -1237,7 +1237,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
|
|||||||
if (widget.metadata.isShow) {
|
if (widget.metadata.isShow) {
|
||||||
_loadSeasonsFromDownloads();
|
_loadSeasonsFromDownloads();
|
||||||
// Get offline OnDeck episode
|
// Get offline OnDeck episode
|
||||||
_loadOfflineOnDeckEpisode();
|
unawaited(_loadOfflineOnDeckEpisode());
|
||||||
} else if (widget.metadata.isSeason) {
|
} else if (widget.metadata.isSeason) {
|
||||||
_seasons = [widget.metadata];
|
_seasons = [widget.metadata];
|
||||||
_showEpisodesDirectly = true;
|
_showEpisodesDirectly = true;
|
||||||
@@ -1286,16 +1286,16 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
|
|||||||
|
|
||||||
// Load seasons if it's a show
|
// Load seasons if it's a show
|
||||||
if (metadata.isShow) {
|
if (metadata.isShow) {
|
||||||
_loadSeasons();
|
unawaited(_loadSeasons());
|
||||||
} else if (metadata.isSeason) {
|
} else if (metadata.isSeason) {
|
||||||
_seasons = [widget.metadata];
|
_seasons = [widget.metadata];
|
||||||
_showEpisodesDirectly = true;
|
_showEpisodesDirectly = true;
|
||||||
_fetchAllEpisodes();
|
unawaited(_fetchAllEpisodes());
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load extras (trailers, behind-the-scenes, etc.)
|
// Load extras (trailers, behind-the-scenes, etc.)
|
||||||
_loadExtras();
|
unawaited(_loadExtras());
|
||||||
_loadRelatedHubs();
|
unawaited(_loadRelatedHubs());
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1307,11 +1307,11 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (widget.metadata.isShow) {
|
if (widget.metadata.isShow) {
|
||||||
_loadSeasons();
|
unawaited(_loadSeasons());
|
||||||
} else if (widget.metadata.isSeason) {
|
} else if (widget.metadata.isSeason) {
|
||||||
_seasons = [widget.metadata];
|
_seasons = [widget.metadata];
|
||||||
_showEpisodesDirectly = true;
|
_showEpisodesDirectly = true;
|
||||||
_fetchAllEpisodes();
|
unawaited(_fetchAllEpisodes());
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Fallback to passed metadata on error
|
// Fallback to passed metadata on error
|
||||||
@@ -1322,11 +1322,11 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (widget.metadata.isShow) {
|
if (widget.metadata.isShow) {
|
||||||
_loadSeasons();
|
unawaited(_loadSeasons());
|
||||||
} else if (widget.metadata.isSeason) {
|
} else if (widget.metadata.isSeason) {
|
||||||
_seasons = [widget.metadata];
|
_seasons = [widget.metadata];
|
||||||
_showEpisodesDirectly = true;
|
_showEpisodesDirectly = true;
|
||||||
_fetchAllEpisodes();
|
unawaited(_fetchAllEpisodes());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1383,7 +1383,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
|
|||||||
await _fetchAllEpisodes();
|
await _fetchAllEpisodes();
|
||||||
} else if (seasonsWithServerId.isNotEmpty) {
|
} else if (seasonsWithServerId.isNotEmpty) {
|
||||||
// Fetch episodes for the auto-selected season
|
// Fetch episodes for the auto-selected season
|
||||||
_fetchSeasonEpisodes(onDeckSeasonIndex);
|
unawaited(_fetchSeasonEpisodes(onDeckSeasonIndex));
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setStateIfMounted(() {
|
setStateIfMounted(() {
|
||||||
@@ -2376,7 +2376,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
|
|||||||
if (context.mounted) {
|
if (context.mounted) {
|
||||||
await navigateToVideoPlayer(context, metadata: firstEpisode);
|
await navigateToVideoPlayer(context, metadata: firstEpisode);
|
||||||
// Refresh metadata when returning from video player
|
// Refresh metadata when returning from video player
|
||||||
_loadFullMetadata();
|
unawaited(_loadFullMetadata());
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Close loading indicator if it's still open
|
// Close loading indicator if it's still open
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import 'dart:async';
|
||||||
import 'dart:io' show Platform;
|
import 'dart:io' show Platform;
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
@@ -128,7 +129,7 @@ class _AppearanceSettingsScreenState extends State<AppearanceSettingsScreen> {
|
|||||||
);
|
);
|
||||||
if (value != null) {
|
if (value != null) {
|
||||||
await _settingsService.write(settings.SettingsService.appLocale, value);
|
await _settingsService.write(settings.SettingsService.appLocale, value);
|
||||||
LocaleSettings.setLocale(value);
|
unawaited(LocaleSettings.setLocale(value));
|
||||||
_restartApp();
|
_restartApp();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import 'dart:async';
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
@@ -141,34 +142,36 @@ class _LogsScreenState extends State<LogsScreen> {
|
|||||||
final data = response.data is String ? jsonDecode(response.data) : response.data;
|
final data = response.data is String ? jsonDecode(response.data) : response.data;
|
||||||
final id = (data as Map<String, dynamic>)['id'] as String;
|
final id = (data as Map<String, dynamic>)['id'] as String;
|
||||||
|
|
||||||
showDialog(
|
unawaited(
|
||||||
context: context,
|
showDialog(
|
||||||
builder: (ctx) => AlertDialog(
|
context: context,
|
||||||
title: Text(t.messages.logsUploaded),
|
builder: (ctx) => AlertDialog(
|
||||||
content: Row(
|
title: Text(t.messages.logsUploaded),
|
||||||
children: [
|
content: Row(
|
||||||
Text('${t.messages.logId}: '),
|
children: [
|
||||||
SelectableText(
|
Text('${t.messages.logId}: '),
|
||||||
id,
|
SelectableText(
|
||||||
style: const TextStyle(fontWeight: FontWeight.bold, fontFamily: 'monospace', fontSize: 18),
|
id,
|
||||||
),
|
style: const TextStyle(fontWeight: FontWeight.bold, fontFamily: 'monospace', fontSize: 18),
|
||||||
const SizedBox(width: 8),
|
),
|
||||||
IconButton(
|
const SizedBox(width: 8),
|
||||||
icon: const Icon(Icons.copy, size: 20),
|
IconButton(
|
||||||
onPressed: () {
|
icon: const Icon(Icons.copy, size: 20),
|
||||||
Clipboard.setData(ClipboardData(text: id));
|
onPressed: () {
|
||||||
showSuccessSnackBar(context, t.messages.logsCopied);
|
Clipboard.setData(ClipboardData(text: id));
|
||||||
},
|
showSuccessSnackBar(context, t.messages.logsCopied);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
FocusableButton(
|
||||||
|
autofocus: true,
|
||||||
|
onPressed: () => Navigator.of(ctx).pop(),
|
||||||
|
child: TextButton(onPressed: () => Navigator.of(ctx).pop(), child: Text(t.common.close)),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
actions: [
|
|
||||||
FocusableButton(
|
|
||||||
autofocus: true,
|
|
||||||
onPressed: () => Navigator.of(ctx).pop(),
|
|
||||||
child: TextButton(onPressed: () => Navigator.of(ctx).pop(), child: Text(t.common.close)),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
|
|||||||
@@ -548,46 +548,48 @@ class _SettingsScreenState extends State<SettingsScreen> with FocusableTab {
|
|||||||
final storageService = DownloadStorageService.instance;
|
final storageService = DownloadStorageService.instance;
|
||||||
final isCustom = storageService.isUsingCustomPath();
|
final isCustom = storageService.isUsingCustomPath();
|
||||||
|
|
||||||
showDialog(
|
unawaited(
|
||||||
context: context,
|
showDialog(
|
||||||
builder: (dialogContext) => AlertDialog(
|
context: context,
|
||||||
title: Text(t.settings.downloads),
|
builder: (dialogContext) => AlertDialog(
|
||||||
content: Column(
|
title: Text(t.settings.downloads),
|
||||||
mainAxisSize: MainAxisSize.min,
|
content: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
Text(t.settings.downloadLocationDescription),
|
children: [
|
||||||
const SizedBox(height: 16),
|
Text(t.settings.downloadLocationDescription),
|
||||||
FutureBuilder<String>(
|
const SizedBox(height: 16),
|
||||||
future: storageService.getCurrentDownloadPathDisplay(),
|
FutureBuilder<String>(
|
||||||
builder: (context, snapshot) {
|
future: storageService.getCurrentDownloadPathDisplay(),
|
||||||
return Text(
|
builder: (context, snapshot) {
|
||||||
t.settings.currentPath(path: snapshot.data ?? '...'),
|
return Text(
|
||||||
style: Theme.of(context).textTheme.bodySmall,
|
t.settings.currentPath(path: snapshot.data ?? '...'),
|
||||||
);
|
style: Theme.of(context).textTheme.bodySmall,
|
||||||
},
|
);
|
||||||
),
|
},
|
||||||
],
|
),
|
||||||
),
|
],
|
||||||
actions: [
|
),
|
||||||
if (isCustom)
|
actions: [
|
||||||
|
if (isCustom)
|
||||||
|
DialogActionButton(
|
||||||
|
onPressed: () async {
|
||||||
|
Navigator.pop(dialogContext);
|
||||||
|
await _resetDownloadLocation();
|
||||||
|
},
|
||||||
|
label: t.settings.resetToDefault,
|
||||||
|
),
|
||||||
|
DialogActionButton(onPressed: () => Navigator.pop(dialogContext), label: t.common.cancel),
|
||||||
DialogActionButton(
|
DialogActionButton(
|
||||||
onPressed: () async {
|
onPressed: () async {
|
||||||
Navigator.pop(dialogContext);
|
Navigator.pop(dialogContext);
|
||||||
await _resetDownloadLocation();
|
await _selectDownloadLocation();
|
||||||
},
|
},
|
||||||
label: t.settings.resetToDefault,
|
label: t.settings.selectFolder,
|
||||||
|
isPrimary: true,
|
||||||
),
|
),
|
||||||
DialogActionButton(onPressed: () => Navigator.pop(dialogContext), label: t.common.cancel),
|
],
|
||||||
DialogActionButton(
|
),
|
||||||
onPressed: () async {
|
|
||||||
Navigator.pop(dialogContext);
|
|
||||||
await _selectDownloadLocation();
|
|
||||||
},
|
|
||||||
label: t.settings.selectFolder,
|
|
||||||
isPrimary: true,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -725,7 +727,7 @@ class _SettingsScreenState extends State<SettingsScreen> with FocusableTab {
|
|||||||
await _keyboardService?.resetToDefaults();
|
await _keyboardService?.resetToDefaults();
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
showSuccessSnackBar(context, t.settings.resetSettingsSuccess);
|
showSuccessSnackBar(context, t.settings.resetSettingsSuccess);
|
||||||
_loadSettings();
|
unawaited(_loadSettings());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -766,7 +768,7 @@ class _SettingsScreenState extends State<SettingsScreen> with FocusableTab {
|
|||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
if (result == null) return; // user cancelled file picker
|
if (result == null) return; // user cancelled file picker
|
||||||
|
|
||||||
LocaleSettings.setLocale(_settingsService.read(settings.SettingsService.appLocale));
|
unawaited(LocaleSettings.setLocale(_settingsService.read(settings.SettingsService.appLocale)));
|
||||||
await Future.wait([
|
await Future.wait([
|
||||||
themeProvider.reload(),
|
themeProvider.reload(),
|
||||||
settingsProvider.reload(),
|
settingsProvider.reload(),
|
||||||
@@ -776,7 +778,7 @@ class _SettingsScreenState extends State<SettingsScreen> with FocusableTab {
|
|||||||
unawaited(librariesProvider.refresh());
|
unawaited(librariesProvider.refresh());
|
||||||
|
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
_loadSettings();
|
unawaited(_loadSettings());
|
||||||
showSuccessSnackBar(context, t.settings.importSettingsSuccess);
|
showSuccessSnackBar(context, t.settings.importSettingsSuccess);
|
||||||
} on NoUserSignedInException {
|
} on NoUserSignedInException {
|
||||||
if (mounted) showErrorSnackBar(context, t.settings.importSettingsNoUser);
|
if (mounted) showErrorSnackBar(context, t.settings.importSettingsNoUser);
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ class _SubtitleStylingScreenState extends State<SubtitleStylingScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _showColorPicker(String title, String currentColor, Function(String) onColorSelected) async {
|
Future<void> _showColorPicker(String title, String currentColor, Function(String) onColorSelected) async {
|
||||||
Color initialColor = _hexToColor(currentColor);
|
final Color initialColor = _hexToColor(currentColor);
|
||||||
|
|
||||||
final Color selectedColor = await showColorPickerDialog(
|
final Color selectedColor = await showColorPickerDialog(
|
||||||
context,
|
context,
|
||||||
|
|||||||
@@ -789,7 +789,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
|||||||
|
|
||||||
// Apply saved volume (clamped to max volume)
|
// Apply saved volume (clamped to max volume)
|
||||||
final savedVolume = settingsService.read(SettingsService.volume).clamp(0.0, maxVolume.toDouble());
|
final savedVolume = settingsService.read(SettingsService.volume).clamp(0.0, maxVolume.toDouble());
|
||||||
player!.setVolume(savedVolume);
|
unawaited(player!.setVolume(savedVolume));
|
||||||
|
|
||||||
// Notify that player is ready
|
// Notify that player is ready
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
@@ -804,7 +804,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Enable wakelock to prevent screen from turning off during playback
|
// Enable wakelock to prevent screen from turning off during playback
|
||||||
_setWakelock(true);
|
unawaited(_setWakelock(true));
|
||||||
appLogger.d('Wakelock enabled for video playback');
|
appLogger.d('Wakelock enabled for video playback');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -822,8 +822,8 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
|||||||
OrientationHelper.setLandscapeOrientation();
|
OrientationHelper.setLandscapeOrientation();
|
||||||
} else {
|
} else {
|
||||||
// Unlocked: Allow all orientations immediately
|
// Unlocked: Allow all orientations immediately
|
||||||
SystemChrome.setPreferredOrientations(DeviceOrientation.values);
|
unawaited(SystemChrome.setPreferredOrientations(DeviceOrientation.values));
|
||||||
SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky);
|
unawaited(SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky));
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
appLogger.w('Failed to set orientation', error: e);
|
appLogger.w('Failed to set orientation', error: e);
|
||||||
@@ -903,7 +903,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
|||||||
_liveStreamFallbackLevel = 0;
|
_liveStreamFallbackLevel = 0;
|
||||||
if (!_hasFirstFrame.value) {
|
if (!_hasFirstFrame.value) {
|
||||||
_hasFirstFrame.value = true;
|
_hasFirstFrame.value = true;
|
||||||
Sentry.addBreadcrumb(Breadcrumb(message: 'First frame ready', category: 'player'));
|
unawaited(Sentry.addBreadcrumb(Breadcrumb(message: 'First frame ready', category: 'player')));
|
||||||
|
|
||||||
// Apply frame rate matching on Android if enabled
|
// Apply frame rate matching on Android if enabled
|
||||||
if (Platform.isAndroid && settingsService.read(SettingsService.matchContentFrameRate)) {
|
if (Platform.isAndroid && settingsService.read(SettingsService.matchContentFrameRate)) {
|
||||||
@@ -958,7 +958,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
|||||||
await _initializeServices();
|
await _initializeServices();
|
||||||
|
|
||||||
// Load next/previous episodes (fire-and-forget)
|
// Load next/previous episodes (fire-and-forget)
|
||||||
_loadAdjacentEpisodes();
|
unawaited(_loadAdjacentEpisodes());
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
appLogger.e('Failed to initialize player', error: e);
|
appLogger.e('Failed to initialize player', error: e);
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
@@ -1038,10 +1038,12 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
|||||||
await player!.play();
|
await player!.play();
|
||||||
}
|
}
|
||||||
|
|
||||||
Sentry.addBreadcrumb(
|
unawaited(
|
||||||
Breadcrumb(
|
Sentry.addBreadcrumb(
|
||||||
message: 'Frame rate matching: ${fps}fps, switched=$didSwitch, delay=${delaySec}s',
|
Breadcrumb(
|
||||||
category: 'player',
|
message: 'Frame rate matching: ${fps}fps, switched=$didSwitch, delay=${delaySec}s',
|
||||||
|
category: 'player',
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
appLogger.d('Frame rate matching: Set display to ${fps}fps (duration: ${durationMs}ms, switched=$didSwitch)');
|
appLogger.d('Frame rate matching: Set display to ${fps}fps (duration: ${durationMs}ms, switched=$didSwitch)');
|
||||||
@@ -1057,7 +1059,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
|||||||
try {
|
try {
|
||||||
await player!.clearVideoFrameRate();
|
await player!.clearVideoFrameRate();
|
||||||
await player!.setProperty('video-sync', 'audio');
|
await player!.setProperty('video-sync', 'audio');
|
||||||
Sentry.addBreadcrumb(Breadcrumb(message: 'Frame rate matching cleared', category: 'player'));
|
unawaited(Sentry.addBreadcrumb(Breadcrumb(message: 'Frame rate matching cleared', category: 'player')));
|
||||||
appLogger.d('Frame rate matching: Cleared, restored default display mode');
|
appLogger.d('Frame rate matching: Cleared, restored default display mode');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
appLogger.d('Failed to clear frame rate matching', error: e);
|
appLogger.d('Failed to clear frame rate matching', error: e);
|
||||||
@@ -1241,9 +1243,9 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
|||||||
|
|
||||||
// Start Discord Rich Presence for current media
|
// Start Discord Rich Presence for current media
|
||||||
if (client != null) {
|
if (client != null) {
|
||||||
DiscordRPCService.instance.startPlayback(_currentMetadata, client);
|
unawaited(DiscordRPCService.instance.startPlayback(_currentMetadata, client));
|
||||||
TraktScrobbleService.instance.startPlayback(_currentMetadata, client, isLive: widget.isLive);
|
unawaited(TraktScrobbleService.instance.startPlayback(_currentMetadata, client, isLive: widget.isLive));
|
||||||
TrackerCoordinator.instance.startPlayback(_currentMetadata, client, isLive: widget.isLive);
|
unawaited(TrackerCoordinator.instance.startPlayback(_currentMetadata, client, isLive: widget.isLive));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1479,10 +1481,10 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
|||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
appLogger.e('Failed to start live TV playback', error: e);
|
appLogger.e('Failed to start live TV playback', error: e);
|
||||||
_sendLiveTimeline('stopped');
|
unawaited(_sendLiveTimeline('stopped'));
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
showErrorSnackBar(context, e.toString());
|
showErrorSnackBar(context, e.toString());
|
||||||
_handleBackButton();
|
unawaited(_handleBackButton());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
@@ -1652,7 +1654,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
|||||||
_videoFilterManager?.enterPipMode();
|
_videoFilterManager?.enterPipMode();
|
||||||
};
|
};
|
||||||
if (player!.state.playing) {
|
if (player!.state.playing) {
|
||||||
_videoPIPManager!.updateAutoPipState(isPlaying: true);
|
unawaited(_videoPIPManager!.updateAutoPipState(isPlaying: true));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1747,10 +1749,12 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Sentry.addBreadcrumb(
|
unawaited(
|
||||||
Breadcrumb(
|
Sentry.addBreadcrumb(
|
||||||
message: 'Pre-playback frame rate: ${preKnownFps}fps, switched=$didSwitch, delay=${delaySec}s',
|
Breadcrumb(
|
||||||
category: 'player',
|
message: 'Pre-playback frame rate: ${preKnownFps}fps, switched=$didSwitch, delay=${delaySec}s',
|
||||||
|
category: 'player',
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -2008,7 +2012,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
|||||||
|
|
||||||
// Persist ambient lighting state
|
// Persist ambient lighting state
|
||||||
final settings = await SettingsService.getInstance();
|
final settings = await SettingsService.getInstance();
|
||||||
settings.write(SettingsService.ambientLighting, ambientLighting.isEnabled);
|
unawaited(settings.write(SettingsService.ambientLighting, ambientLighting.isEnabled));
|
||||||
|
|
||||||
if (mounted) setState(() {});
|
if (mounted) setState(() {});
|
||||||
}
|
}
|
||||||
@@ -2109,7 +2113,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
|||||||
|
|
||||||
// Use same navigation as local episode change (pushReplacement from player context)
|
// Use same navigation as local episode change (pushReplacement from player context)
|
||||||
_isReplacingWithVideo = true;
|
_isReplacingWithVideo = true;
|
||||||
navigateToVideoPlayer(context, metadata: metadata, usePushReplacement: true);
|
unawaited(navigateToVideoPlayer(context, metadata: metadata, usePushReplacement: true));
|
||||||
}
|
}
|
||||||
|
|
||||||
void _setupCompanionRemoteCallbacks() {
|
void _setupCompanionRemoteCallbacks() {
|
||||||
@@ -2150,23 +2154,23 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
|||||||
final settings = await SettingsService.getInstance();
|
final settings = await SettingsService.getInstance();
|
||||||
final maxVol = settings.read(SettingsService.maxVolume).toDouble();
|
final maxVol = settings.read(SettingsService.maxVolume).toDouble();
|
||||||
final newVolume = (player!.state.volume + 10).clamp(0.0, maxVol);
|
final newVolume = (player!.state.volume + 10).clamp(0.0, maxVol);
|
||||||
player!.setVolume(newVolume);
|
unawaited(player!.setVolume(newVolume));
|
||||||
settings.write(SettingsService.volume, newVolume);
|
unawaited(settings.write(SettingsService.volume, newVolume));
|
||||||
};
|
};
|
||||||
receiver.onVolumeDown = () async {
|
receiver.onVolumeDown = () async {
|
||||||
if (player == null) return;
|
if (player == null) return;
|
||||||
final settings = await SettingsService.getInstance();
|
final settings = await SettingsService.getInstance();
|
||||||
final maxVol = settings.read(SettingsService.maxVolume).toDouble();
|
final maxVol = settings.read(SettingsService.maxVolume).toDouble();
|
||||||
final newVolume = (player!.state.volume - 10).clamp(0.0, maxVol);
|
final newVolume = (player!.state.volume - 10).clamp(0.0, maxVol);
|
||||||
player!.setVolume(newVolume);
|
unawaited(player!.setVolume(newVolume));
|
||||||
settings.write(SettingsService.volume, newVolume);
|
unawaited(settings.write(SettingsService.volume, newVolume));
|
||||||
};
|
};
|
||||||
receiver.onVolumeMute = () async {
|
receiver.onVolumeMute = () async {
|
||||||
if (player == null) return;
|
if (player == null) return;
|
||||||
final settings = await SettingsService.getInstance();
|
final settings = await SettingsService.getInstance();
|
||||||
final newVolume = player!.state.volume > 0 ? 0.0 : 100.0;
|
final newVolume = player!.state.volume > 0 ? 0.0 : 100.0;
|
||||||
player!.setVolume(newVolume);
|
unawaited(player!.setVolume(newVolume));
|
||||||
settings.write(SettingsService.volume, newVolume);
|
unawaited(settings.write(SettingsService.volume, newVolume));
|
||||||
};
|
};
|
||||||
receiver.onSubtitles = _cycleSubtitleTrack;
|
receiver.onSubtitles = _cycleSubtitleTrack;
|
||||||
receiver.onAudioTracks = _cycleAudioTrack;
|
receiver.onAudioTracks = _cycleAudioTrack;
|
||||||
@@ -2470,13 +2474,13 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
|||||||
|
|
||||||
// mpv does not flip the `pause` property on EOF, so _onPlayingStateChanged
|
// mpv does not flip the `pause` property on EOF, so _onPlayingStateChanged
|
||||||
// never fires false. Normalize all playback-dependent state.
|
// never fires false. Normalize all playback-dependent state.
|
||||||
_setWakelock(false);
|
unawaited(_setWakelock(false));
|
||||||
_progressTracker?.sendProgress('paused');
|
unawaited(_progressTracker?.sendProgress('paused'));
|
||||||
_updateMediaControlsPlaybackState();
|
_updateMediaControlsPlaybackState();
|
||||||
DiscordRPCService.instance.pausePlayback();
|
unawaited(DiscordRPCService.instance.pausePlayback());
|
||||||
TraktScrobbleService.instance.pausePlayback();
|
unawaited(TraktScrobbleService.instance.pausePlayback());
|
||||||
if (_autoPipEnabled) {
|
if (_autoPipEnabled) {
|
||||||
_videoPIPManager?.updateAutoPipState(isPlaying: false);
|
unawaited(_videoPIPManager?.updateAutoPipState(isPlaying: false));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (_nextEpisode != null && !_showPlayNextDialog && !_showStillWatchingPrompt && !_completionTriggered) {
|
if (_nextEpisode != null && !_showPlayNextDialog && !_showStillWatchingPrompt && !_completionTriggered) {
|
||||||
@@ -2484,7 +2488,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
|||||||
|
|
||||||
// PiP: skip dialog (user can't interact), auto-play immediately
|
// PiP: skip dialog (user can't interact), auto-play immediately
|
||||||
if (PipService().isPipActive.value) {
|
if (PipService().isPipActive.value) {
|
||||||
_playNext();
|
unawaited(_playNext());
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2514,7 +2518,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
|||||||
}
|
}
|
||||||
} else if (_nextEpisode == null && !_completionTriggered) {
|
} else if (_nextEpisode == null && !_completionTriggered) {
|
||||||
_completionTriggered = true;
|
_completionTriggered = true;
|
||||||
_handleBackButton();
|
unawaited(_handleBackButton());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2560,7 +2564,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
|||||||
Future<void> _showServerLimitDialog() async {
|
Future<void> _showServerLimitDialog() async {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
await showServerLimitDialog(context);
|
await showServerLimitDialog(context);
|
||||||
if (mounted) _handleBackButton();
|
if (mounted) unawaited(_handleBackButton());
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Handle notification when native player switched from ExoPlayer to MPV
|
/// Handle notification when native player switched from ExoPlayer to MPV
|
||||||
@@ -2606,7 +2610,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
|||||||
Future<void> _restoreMediaControlsAfterResume() async {
|
Future<void> _restoreMediaControlsAfterResume() async {
|
||||||
if (!_isPlayerInitialized || !mounted) return;
|
if (!_isPlayerInitialized || !mounted) return;
|
||||||
|
|
||||||
_setWakelock(player?.state.isActive ?? false);
|
unawaited(_setWakelock(player?.state.isActive ?? false));
|
||||||
|
|
||||||
final manager = _mediaControlsManager;
|
final manager = _mediaControlsManager;
|
||||||
final currentPlayer = player;
|
final currentPlayer = player;
|
||||||
@@ -2770,7 +2774,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
|||||||
widget.liveDvrKey == null) {
|
widget.liveDvrKey == null) {
|
||||||
appLogger.w('Cannot retry live stream — missing session info');
|
appLogger.w('Cannot retry live stream — missing session info');
|
||||||
showGlobalErrorSnackBar(_lastLogError ?? 'Live stream failed');
|
showGlobalErrorSnackBar(_lastLogError ?? 'Live stream failed');
|
||||||
_handleBackButton();
|
unawaited(_handleBackButton());
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2783,7 +2787,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
|||||||
final tuneResult = await client.tuneChannel(widget.liveDvrKey!, channel.key);
|
final tuneResult = await client.tuneChannel(widget.liveDvrKey!, channel.key);
|
||||||
if (tuneResult == null || !mounted) {
|
if (tuneResult == null || !mounted) {
|
||||||
showGlobalErrorSnackBar(_lastLogError ?? 'Live stream failed');
|
showGlobalErrorSnackBar(_lastLogError ?? 'Live stream failed');
|
||||||
_handleBackButton();
|
unawaited(_handleBackButton());
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2800,7 +2804,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
|||||||
);
|
);
|
||||||
if (streamPath == null || !mounted) {
|
if (streamPath == null || !mounted) {
|
||||||
showGlobalErrorSnackBar(_lastLogError ?? 'Live stream failed');
|
showGlobalErrorSnackBar(_lastLogError ?? 'Live stream failed');
|
||||||
_handleBackButton();
|
unawaited(_handleBackButton());
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3128,18 +3132,20 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
|||||||
_isReplacingWithVideo = true;
|
_isReplacingWithVideo = true;
|
||||||
|
|
||||||
// Clear Discord Rich Presence + Trakt scrobble before switching episodes
|
// Clear Discord Rich Presence + Trakt scrobble before switching episodes
|
||||||
DiscordRPCService.instance.stopPlayback();
|
unawaited(DiscordRPCService.instance.stopPlayback());
|
||||||
TraktScrobbleService.instance.stopPlayback();
|
unawaited(TraktScrobbleService.instance.stopPlayback());
|
||||||
TrackerCoordinator.instance.stopPlayback();
|
unawaited(TrackerCoordinator.instance.stopPlayback());
|
||||||
|
|
||||||
// If player isn't available, navigate without preserving settings
|
// If player isn't available, navigate without preserving settings
|
||||||
if (player == null) {
|
if (player == null) {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
navigateToVideoPlayer(
|
unawaited(
|
||||||
context,
|
navigateToVideoPlayer(
|
||||||
metadata: episodeMetadata,
|
context,
|
||||||
usePushReplacement: true,
|
metadata: episodeMetadata,
|
||||||
isOffline: widget.isOffline,
|
usePushReplacement: true,
|
||||||
|
isOffline: widget.isOffline,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
@@ -3150,11 +3156,13 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
|||||||
if (currentPlayer == null) {
|
if (currentPlayer == null) {
|
||||||
// Player already disposed, navigate without preserving settings
|
// Player already disposed, navigate without preserving settings
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
navigateToVideoPlayer(
|
unawaited(
|
||||||
context,
|
navigateToVideoPlayer(
|
||||||
metadata: episodeMetadata,
|
context,
|
||||||
usePushReplacement: true,
|
metadata: episodeMetadata,
|
||||||
isOffline: widget.isOffline,
|
usePushReplacement: true,
|
||||||
|
isOffline: widget.isOffline,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
@@ -3165,7 +3173,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
|||||||
final currentSecondarySubtitleTrack = currentPlayer.state.track.secondarySubtitle;
|
final currentSecondarySubtitleTrack = currentPlayer.state.track.secondarySubtitle;
|
||||||
|
|
||||||
// Pause and stop current playback
|
// Pause and stop current playback
|
||||||
currentPlayer.pause();
|
unawaited(currentPlayer.pause());
|
||||||
await _progressTracker?.sendProgress('stopped');
|
await _progressTracker?.sendProgress('stopped');
|
||||||
_progressTracker?.stopTracking();
|
_progressTracker?.stopTracking();
|
||||||
|
|
||||||
@@ -3174,14 +3182,16 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
|||||||
|
|
||||||
// Navigate to the episode using pushReplacement to destroy current player
|
// Navigate to the episode using pushReplacement to destroy current player
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
navigateToVideoPlayer(
|
unawaited(
|
||||||
context,
|
navigateToVideoPlayer(
|
||||||
metadata: episodeMetadata,
|
context,
|
||||||
preferredAudioTrack: currentAudioTrack,
|
metadata: episodeMetadata,
|
||||||
preferredSubtitleTrack: currentSubtitleTrack,
|
preferredAudioTrack: currentAudioTrack,
|
||||||
preferredSecondarySubtitleTrack: currentSecondarySubtitleTrack,
|
preferredSubtitleTrack: currentSubtitleTrack,
|
||||||
usePushReplacement: true,
|
preferredSecondarySubtitleTrack: currentSecondarySubtitleTrack,
|
||||||
isOffline: widget.isOffline,
|
usePushReplacement: true,
|
||||||
|
isOffline: widget.isOffline,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3209,9 +3219,9 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
|||||||
_progressTracker?.stopTracking();
|
_progressTracker?.stopTracking();
|
||||||
_progressTracker?.dispose();
|
_progressTracker?.dispose();
|
||||||
_progressTracker = null;
|
_progressTracker = null;
|
||||||
DiscordRPCService.instance.stopPlayback();
|
unawaited(DiscordRPCService.instance.stopPlayback());
|
||||||
TraktScrobbleService.instance.stopPlayback();
|
unawaited(TraktScrobbleService.instance.stopPlayback());
|
||||||
TrackerCoordinator.instance.stopPlayback();
|
unawaited(TrackerCoordinator.instance.stopPlayback());
|
||||||
|
|
||||||
_currentMetadata = episodeMetadata;
|
_currentMetadata = episodeMetadata;
|
||||||
_activeRatingKey = episodeMetadata.ratingKey;
|
_activeRatingKey = episodeMetadata.ratingKey;
|
||||||
@@ -3323,9 +3333,9 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (client != null) {
|
if (client != null) {
|
||||||
DiscordRPCService.instance.startPlayback(episodeMetadata, client);
|
unawaited(DiscordRPCService.instance.startPlayback(episodeMetadata, client));
|
||||||
TraktScrobbleService.instance.startPlayback(episodeMetadata, client, isLive: widget.isLive);
|
unawaited(TraktScrobbleService.instance.startPlayback(episodeMetadata, client, isLive: widget.isLive));
|
||||||
TrackerCoordinator.instance.startPlayback(episodeMetadata, client, isLive: widget.isLive);
|
unawaited(TrackerCoordinator.instance.startPlayback(episodeMetadata, client, isLive: widget.isLive));
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -3337,7 +3347,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
|||||||
await _loadAdjacentEpisodes();
|
await _loadAdjacentEpisodes();
|
||||||
|
|
||||||
if (_autoPipEnabled) {
|
if (_autoPipEnabled) {
|
||||||
_videoPIPManager?.updateAutoPipState(isPlaying: currentPlayer.state.playing);
|
unawaited(_videoPIPManager?.updateAutoPipState(isPlaying: currentPlayer.state.playing));
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
_isSwappingEpisode = false;
|
_isSwappingEpisode = false;
|
||||||
|
|||||||
@@ -153,7 +153,7 @@ class CompanionRemotePeerService with KeepaliveMixin {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
request.response.statusCode = HttpStatus.notFound;
|
request.response.statusCode = HttpStatus.notFound;
|
||||||
request.response.close();
|
unawaited(request.response.close());
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -229,7 +229,7 @@ class CompanionRemotePeerService with KeepaliveMixin {
|
|||||||
deviceName == null ||
|
deviceName == null ||
|
||||||
platform == null) {
|
platform == null) {
|
||||||
socket.add(jsonEncode({'type': 'authFailed'}));
|
socket.add(jsonEncode({'type': 'authFailed'}));
|
||||||
socket.close(4003, 'Authentication failed');
|
unawaited(socket.close(4003, 'Authentication failed'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -240,7 +240,7 @@ class CompanionRemotePeerService with KeepaliveMixin {
|
|||||||
_recordFailedAuth(sourceIp);
|
_recordFailedAuth(sourceIp);
|
||||||
appLogger.w('CompanionRemote: Auth failed — unknown user');
|
appLogger.w('CompanionRemote: Auth failed — unknown user');
|
||||||
socket.add(jsonEncode({'type': 'authFailed'}));
|
socket.add(jsonEncode({'type': 'authFailed'}));
|
||||||
socket.close(4003, 'Authentication failed');
|
unawaited(socket.close(4003, 'Authentication failed'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -261,7 +261,7 @@ class CompanionRemotePeerService with KeepaliveMixin {
|
|||||||
_recordFailedAuth(sourceIp);
|
_recordFailedAuth(sourceIp);
|
||||||
appLogger.w('CompanionRemote: Auth failed — invalid auth tag');
|
appLogger.w('CompanionRemote: Auth failed — invalid auth tag');
|
||||||
socket.add(jsonEncode({'type': 'authFailed'}));
|
socket.add(jsonEncode({'type': 'authFailed'}));
|
||||||
socket.close(4003, 'Authentication failed');
|
unawaited(socket.close(4003, 'Authentication failed'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -275,7 +275,7 @@ class CompanionRemotePeerService with KeepaliveMixin {
|
|||||||
// Close existing client if present
|
// Close existing client if present
|
||||||
if (_clientSocket != null) {
|
if (_clientSocket != null) {
|
||||||
appLogger.d('CompanionRemote: Replacing existing client connection');
|
appLogger.d('CompanionRemote: Replacing existing client connection');
|
||||||
_clientSocket!.close(4004, 'Replaced by new connection');
|
unawaited(_clientSocket!.close(4004, 'Replaced by new connection'));
|
||||||
}
|
}
|
||||||
|
|
||||||
_clientSocket = socket;
|
_clientSocket = socket;
|
||||||
@@ -298,7 +298,7 @@ class CompanionRemotePeerService with KeepaliveMixin {
|
|||||||
sendDeviceInfo(hostDeviceName, hostPlatform);
|
sendDeviceInfo(hostDeviceName, hostPlatform);
|
||||||
} else {
|
} else {
|
||||||
appLogger.w('CompanionRemote: Expected auth, got ${json['type']}');
|
appLogger.w('CompanionRemote: Expected auth, got ${json['type']}');
|
||||||
socket.close(4002, 'Authentication required');
|
unawaited(socket.close(4002, 'Authentication required'));
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Encrypted command — data is binary
|
// Encrypted command — data is binary
|
||||||
|
|||||||
@@ -112,7 +112,7 @@ class DiscordRPCService {
|
|||||||
|
|
||||||
if (_isEnabled && _isConnected) {
|
if (_isEnabled && _isConnected) {
|
||||||
// Upload thumbnail in background, don't block playback
|
// Upload thumbnail in background, don't block playback
|
||||||
_uploadThumbnailAndUpdatePresence();
|
unawaited(_uploadThumbnailAndUpdatePresence());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -183,7 +183,7 @@ class DiscordRPCService {
|
|||||||
/// Clear the presence
|
/// Clear the presence
|
||||||
Future<void> clearPresence() async {
|
Future<void> clearPresence() async {
|
||||||
try {
|
try {
|
||||||
_rpc?.clearPresence();
|
unawaited(_rpc?.clearPresence());
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
appLogger.d('Failed to clear Discord presence', error: e);
|
appLogger.d('Failed to clear Discord presence', error: e);
|
||||||
}
|
}
|
||||||
@@ -237,7 +237,7 @@ class DiscordRPCService {
|
|||||||
_disconnectedSubscription = null;
|
_disconnectedSubscription = null;
|
||||||
_errorSubscription = null;
|
_errorSubscription = null;
|
||||||
try {
|
try {
|
||||||
_rpc?.dispose();
|
unawaited(_rpc?.dispose());
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
appLogger.d('DiscordRPC: dispose ignored', error: e);
|
appLogger.d('DiscordRPC: dispose ignored', error: e);
|
||||||
}
|
}
|
||||||
@@ -258,7 +258,7 @@ class DiscordRPCService {
|
|||||||
_errorSubscription = null;
|
_errorSubscription = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
_rpc?.dispose();
|
unawaited(_rpc?.dispose());
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
appLogger.d('Error disposing Discord RPC', error: e);
|
appLogger.d('Error disposing Discord RPC', error: e);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -201,11 +201,11 @@ class DownloadManagerService {
|
|||||||
/// then scans drift for orphaned items.
|
/// then scans drift for orphaned items.
|
||||||
Future<void> recoverInterruptedDownloads() async {
|
Future<void> recoverInterruptedDownloads() async {
|
||||||
try {
|
try {
|
||||||
Sentry.addBreadcrumb(Breadcrumb(message: 'Initializing FileDownloader', category: 'downloads'));
|
unawaited(Sentry.addBreadcrumb(Breadcrumb(message: 'Initializing FileDownloader', category: 'downloads')));
|
||||||
await _initializeFileDownloader();
|
await _initializeFileDownloader();
|
||||||
|
|
||||||
// Let background_downloader re-enqueue tasks killed by the OS
|
// Let background_downloader re-enqueue tasks killed by the OS
|
||||||
Sentry.addBreadcrumb(Breadcrumb(message: 'Rescheduling killed tasks', category: 'downloads'));
|
unawaited(Sentry.addBreadcrumb(Breadcrumb(message: 'Rescheduling killed tasks', category: 'downloads')));
|
||||||
final (rescheduled, _) = await FileDownloader().rescheduleKilledTasks();
|
final (rescheduled, _) = await FileDownloader().rescheduleKilledTasks();
|
||||||
if (rescheduled.isNotEmpty) {
|
if (rescheduled.isNotEmpty) {
|
||||||
appLogger.i('Rescheduled ${rescheduled.length} killed download task(s)');
|
appLogger.i('Rescheduled ${rescheduled.length} killed download task(s)');
|
||||||
@@ -251,7 +251,7 @@ class DownloadManagerService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Scan drift for orphaned items stuck in 'downloading'
|
// Scan drift for orphaned items stuck in 'downloading'
|
||||||
Sentry.addBreadcrumb(Breadcrumb(message: 'Scanning for orphaned downloads', category: 'downloads'));
|
unawaited(Sentry.addBreadcrumb(Breadcrumb(message: 'Scanning for orphaned downloads', category: 'downloads')));
|
||||||
final allDownloads = await _database.select(_database.downloadedMedia).get();
|
final allDownloads = await _database.select(_database.downloadedMedia).get();
|
||||||
for (final item in allDownloads) {
|
for (final item in allDownloads) {
|
||||||
if (item.status == DownloadStatus.downloading.index) {
|
if (item.status == DownloadStatus.downloading.index) {
|
||||||
@@ -496,7 +496,7 @@ class DownloadManagerService {
|
|||||||
_emitProgress(globalKey, DownloadStatus.queued, 0);
|
_emitProgress(globalKey, DownloadStatus.queued, 0);
|
||||||
|
|
||||||
// Start processing if not already
|
// Start processing if not already
|
||||||
_processQueue(client);
|
unawaited(_processQueue(client));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Process the download queue — prepares and enqueues items with background_downloader.
|
/// Process the download queue — prepares and enqueues items with background_downloader.
|
||||||
@@ -812,7 +812,7 @@ class DownloadManagerService {
|
|||||||
await _transitionStatus(globalKey, DownloadStatus.queued);
|
await _transitionStatus(globalKey, DownloadStatus.queued);
|
||||||
await _database.addToQueue(mediaGlobalKey: globalKey);
|
await _database.addToQueue(mediaGlobalKey: globalKey);
|
||||||
final client = _getClient(parseGlobalKey(globalKey)?.serverId);
|
final client = _getClient(parseGlobalKey(globalKey)?.serverId);
|
||||||
if (client != null) _processQueue(client);
|
if (client != null) unawaited(_processQueue(client));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Handle a failed download — auto-retry if retries remain, otherwise permanently fail.
|
/// Handle a failed download — auto-retry if retries remain, otherwise permanently fail.
|
||||||
@@ -860,7 +860,7 @@ class DownloadManagerService {
|
|||||||
|
|
||||||
// Only advance the queue if the download actually started transferring.
|
// Only advance the queue if the download actually started transferring.
|
||||||
// Instant failures (DNS, connection) would just cause the next item to fail too.
|
// Instant failures (DNS, connection) would just cause the next item to fail too.
|
||||||
if (hadProgress) _processQueue(client);
|
if (hadProgress) unawaited(_processQueue(client));
|
||||||
} else {
|
} else {
|
||||||
if (isNetworkError) {
|
if (isNetworkError) {
|
||||||
appLogger.w('Network error for $globalKey, failing permanently (no auto-retry): $errorMessage');
|
appLogger.w('Network error for $globalKey, failing permanently (no auto-retry): $errorMessage');
|
||||||
@@ -891,7 +891,7 @@ class DownloadManagerService {
|
|||||||
|
|
||||||
// Try to enqueue more items from the queue
|
// Try to enqueue more items from the queue
|
||||||
final client = _getClient(parseGlobalKey(globalKey)?.serverId);
|
final client = _getClient(parseGlobalKey(globalKey)?.serverId);
|
||||||
if (client != null) _processQueue(client);
|
if (client != null) unawaited(_processQueue(client));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Execute an app-level auto-retry: transition back to queued and re-enqueue.
|
/// Execute an app-level auto-retry: transition back to queued and re-enqueue.
|
||||||
@@ -913,7 +913,7 @@ class DownloadManagerService {
|
|||||||
await _database.updateBgTaskId(globalKey, null);
|
await _database.updateBgTaskId(globalKey, null);
|
||||||
await _transitionStatus(globalKey, DownloadStatus.queued);
|
await _transitionStatus(globalKey, DownloadStatus.queued);
|
||||||
await _database.addToQueue(mediaGlobalKey: globalKey);
|
await _database.addToQueue(mediaGlobalKey: globalKey);
|
||||||
_processQueue(client);
|
unawaited(_processQueue(client));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Handle a completed video download — store path, download supplementary content, mark done.
|
/// Handle a completed video download — store path, download supplementary content, mark done.
|
||||||
@@ -1034,7 +1034,7 @@ class DownloadManagerService {
|
|||||||
_completingKeys.remove(globalKey);
|
_completingKeys.remove(globalKey);
|
||||||
// Always advance the queue, even after errors
|
// Always advance the queue, even after errors
|
||||||
final nextClient = _getClient(parseGlobalKey(globalKey)?.serverId);
|
final nextClient = _getClient(parseGlobalKey(globalKey)?.serverId);
|
||||||
if (nextClient != null) _processQueue(nextClient);
|
if (nextClient != null) unawaited(_processQueue(nextClient));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1372,7 +1372,7 @@ class DownloadManagerService {
|
|||||||
await _transitionStatus(globalKey, DownloadStatus.queued);
|
await _transitionStatus(globalKey, DownloadStatus.queued);
|
||||||
await _database.addToQueue(mediaGlobalKey: globalKey);
|
await _database.addToQueue(mediaGlobalKey: globalKey);
|
||||||
final resolvedClient = _getClient(parseGlobalKey(globalKey)?.serverId) ?? client;
|
final resolvedClient = _getClient(parseGlobalKey(globalKey)?.serverId) ?? client;
|
||||||
_processQueue(resolvedClient);
|
unawaited(_processQueue(resolvedClient));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Retry a failed download
|
/// Retry a failed download
|
||||||
@@ -1384,7 +1384,7 @@ class DownloadManagerService {
|
|||||||
await _transitionStatus(globalKey, DownloadStatus.queued);
|
await _transitionStatus(globalKey, DownloadStatus.queued);
|
||||||
await _database.addToQueue(mediaGlobalKey: globalKey);
|
await _database.addToQueue(mediaGlobalKey: globalKey);
|
||||||
final resolvedClient = _getClient(parseGlobalKey(globalKey)?.serverId) ?? client;
|
final resolvedClient = _getClient(parseGlobalKey(globalKey)?.serverId) ?? client;
|
||||||
_processQueue(resolvedClient);
|
unawaited(_processQueue(resolvedClient));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Cancel a download
|
/// Cancel a download
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
@@ -89,13 +91,15 @@ class EpisodeNavigationService {
|
|||||||
|
|
||||||
// Navigate to the new episode
|
// Navigate to the new episode
|
||||||
if (context.mounted) {
|
if (context.mounted) {
|
||||||
navigateToVideoPlayer(
|
unawaited(
|
||||||
context,
|
navigateToVideoPlayer(
|
||||||
metadata: episode,
|
context,
|
||||||
preferredAudioTrack: currentAudioTrack,
|
metadata: episode,
|
||||||
preferredSubtitleTrack: currentSubtitleTrack,
|
preferredAudioTrack: currentAudioTrack,
|
||||||
preferredSecondarySubtitleTrack: currentSecondarySubtitleTrack,
|
preferredSubtitleTrack: currentSubtitleTrack,
|
||||||
usePushReplacement: usePushReplacement,
|
preferredSecondarySubtitleTrack: currentSecondarySubtitleTrack,
|
||||||
|
usePushReplacement: usePushReplacement,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -86,7 +86,7 @@ class GamepadService with WindowListener {
|
|||||||
_windowFocused = await windowManager.isFocused();
|
_windowFocused = await windowManager.isFocused();
|
||||||
}
|
}
|
||||||
|
|
||||||
_subscription?.cancel();
|
unawaited(_subscription?.cancel());
|
||||||
_subscription = Gamepad.instance.events.listen(
|
_subscription = Gamepad.instance.events.listen(
|
||||||
_handleGamepadEvent,
|
_handleGamepadEvent,
|
||||||
onError: (e) => appLogger.e('GamepadService: Stream error', error: e),
|
onError: (e) => appLogger.e('GamepadService: Stream error', error: e),
|
||||||
@@ -138,11 +138,11 @@ class GamepadService with WindowListener {
|
|||||||
|
|
||||||
void _handleGamepadEvent(GamepadEvent event) {
|
void _handleGamepadEvent(GamepadEvent event) {
|
||||||
switch (event) {
|
switch (event) {
|
||||||
case GamepadConnectionEvent e:
|
case final GamepadConnectionEvent e:
|
||||||
appLogger.i('GamepadService: Gamepad ${e.connected ? "connected" : "disconnected"}: ${e.info.name}');
|
appLogger.i('GamepadService: Gamepad ${e.connected ? "connected" : "disconnected"}: ${e.info.name}');
|
||||||
case GamepadButtonEvent e:
|
case final GamepadButtonEvent e:
|
||||||
_handleButton(e);
|
_handleButton(e);
|
||||||
case GamepadAxisEvent e:
|
case final GamepadAxisEvent e:
|
||||||
_handleAxis(e);
|
_handleAxis(e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -206,7 +206,9 @@ class MultiServerManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
appLogger.i('Connecting to ${servers.length} servers...');
|
appLogger.i('Connecting to ${servers.length} servers...');
|
||||||
Sentry.addBreadcrumb(Breadcrumb(message: 'Connecting to ${servers.length} server(s)', category: 'servers'));
|
unawaited(
|
||||||
|
Sentry.addBreadcrumb(Breadcrumb(message: 'Connecting to ${servers.length} server(s)', category: 'servers')),
|
||||||
|
);
|
||||||
|
|
||||||
// Re-use the persisted client ID so Plex doesn't see a "new device" on
|
// Re-use the persisted client ID so Plex doesn't see a "new device" on
|
||||||
// every reconnect.
|
// every reconnect.
|
||||||
@@ -531,7 +533,11 @@ class MultiServerManager {
|
|||||||
if (offline.isEmpty) return;
|
if (offline.isEmpty) return;
|
||||||
|
|
||||||
appLogger.d('Attempting reconnection for ${offline.length} offline servers');
|
appLogger.d('Attempting reconnection for ${offline.length} offline servers');
|
||||||
Sentry.addBreadcrumb(Breadcrumb(message: 'Reconnecting ${offline.length} offline server(s)', category: 'servers'));
|
unawaited(
|
||||||
|
Sentry.addBreadcrumb(
|
||||||
|
Breadcrumb(message: 'Reconnecting ${offline.length} offline server(s)', category: 'servers'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
if (forceRediscovery) {
|
if (forceRediscovery) {
|
||||||
final storage = await StorageService.getInstance();
|
final storage = await StorageService.getInstance();
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
@@ -253,13 +255,15 @@ class PlayQueueLauncher {
|
|||||||
// Show loading indicator
|
// Show loading indicator
|
||||||
if (showLoading && context.mounted) {
|
if (showLoading && context.mounted) {
|
||||||
loadingVisible = true;
|
loadingVisible = true;
|
||||||
showDialog(
|
unawaited(
|
||||||
context: context,
|
showDialog(
|
||||||
barrierDismissible: false,
|
context: context,
|
||||||
builder: (dialogContext) {
|
barrierDismissible: false,
|
||||||
loadingDialogContext = dialogContext;
|
builder: (dialogContext) {
|
||||||
return const Center(child: CircularProgressIndicator());
|
loadingDialogContext = dialogContext;
|
||||||
},
|
return const Center(child: CircularProgressIndicator());
|
||||||
|
},
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -128,20 +128,22 @@ class PlaybackProgressTracker {
|
|||||||
_resetBackoff();
|
_resetBackoff();
|
||||||
} else {
|
} else {
|
||||||
// Fire-and-forget for playing/paused — avoid blocking the Dart event loop
|
// Fire-and-forget for playing/paused — avoid blocking the Dart event loop
|
||||||
_sendOnlineProgress(state, position, duration)
|
unawaited(
|
||||||
.then((_) {
|
_sendOnlineProgress(state, position, duration)
|
||||||
_resetBackoff();
|
.then((_) {
|
||||||
})
|
_resetBackoff();
|
||||||
.catchError((Object e) {
|
})
|
||||||
_consecutiveFailures++;
|
.catchError((Object e) {
|
||||||
// Exponential backoff: skip 1, 2, 4, 8... ticks (capped at 6 ≈ 60s)
|
_consecutiveFailures++;
|
||||||
_ticksToSkip = (1 << (_consecutiveFailures - 1)).clamp(1, 6);
|
// Exponential backoff: skip 1, 2, 4, 8... ticks (capped at 6 ≈ 60s)
|
||||||
appLogger.d(
|
_ticksToSkip = (1 << (_consecutiveFailures - 1)).clamp(1, 6);
|
||||||
'Progress update failed ($_consecutiveFailures consecutive), '
|
appLogger.d(
|
||||||
'skipping next $_ticksToSkip tick(s)',
|
'Progress update failed ($_consecutiveFailures consecutive), '
|
||||||
error: e,
|
'skipping next $_ticksToSkip tick(s)',
|
||||||
);
|
error: e,
|
||||||
});
|
);
|
||||||
|
}),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Emit watch state event on stop for UI updates across screens.
|
// Emit watch state event on stop for UI updates across screens.
|
||||||
|
|||||||
@@ -462,36 +462,38 @@ class PlexServer {
|
|||||||
appLogger.d('Running connection race to find first working endpoint', error: {'candidateCount': totalCandidates});
|
appLogger.d('Running connection race to find first working endpoint', error: {'candidateCount': totalCandidates});
|
||||||
|
|
||||||
for (final candidate in candidates) {
|
for (final candidate in candidates) {
|
||||||
PlexClient.testConnectionWithLatency(
|
unawaited(
|
||||||
candidate.url,
|
PlexClient.testConnectionWithLatency(
|
||||||
accessToken,
|
candidate.url,
|
||||||
timeout: raceTimeout,
|
accessToken,
|
||||||
clientIdentifier: clientIdentifier,
|
timeout: raceTimeout,
|
||||||
).then((result) {
|
clientIdentifier: clientIdentifier,
|
||||||
completedTests++;
|
).then((result) {
|
||||||
|
completedTests++;
|
||||||
|
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
appLogger.w(
|
appLogger.w(
|
||||||
'Connection candidate failed',
|
'Connection candidate failed',
|
||||||
error: {
|
error: {
|
||||||
'url': candidate.url,
|
'url': candidate.url,
|
||||||
'type': candidate.connection.displayType,
|
'type': candidate.connection.displayType,
|
||||||
'https': candidate.isHttps,
|
'https': candidate.isHttps,
|
||||||
'error': result.error,
|
'error': result.error,
|
||||||
'latencyMs': result.latencyMs,
|
'latencyMs': result.latencyMs,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (result.success && !completer.isCompleted) {
|
if (result.success && !completer.isCompleted) {
|
||||||
if (result.transcoderVideo != null) onTranscoderCapability?.call(result.transcoderVideo!);
|
if (result.transcoderVideo != null) onTranscoderCapability?.call(result.transcoderVideo!);
|
||||||
completer.complete(candidate);
|
completer.complete(candidate);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (completedTests == candidates.length && !completer.isCompleted) {
|
if (completedTests == candidates.length && !completer.isCompleted) {
|
||||||
completer.complete(null);
|
completer.complete(null);
|
||||||
}
|
}
|
||||||
});
|
}),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
firstCandidate = await completer.future;
|
firstCandidate = await completer.future;
|
||||||
|
|||||||
@@ -922,7 +922,7 @@ class PlexClient {
|
|||||||
|
|
||||||
if (streams == null) return (audio: audioTracks, subtitles: subtitleTracks, frameRate: frameRate);
|
if (streams == null) return (audio: audioTracks, subtitles: subtitleTracks, frameRate: frameRate);
|
||||||
|
|
||||||
for (var stream in streams) {
|
for (final stream in streams) {
|
||||||
final streamType = stream['streamType'] as int?;
|
final streamType = stream['streamType'] as int?;
|
||||||
|
|
||||||
if (streamType == PlexStreamType.video) {
|
if (streamType == PlexStreamType.video) {
|
||||||
@@ -1382,7 +1382,7 @@ class PlexClient {
|
|||||||
Map<String, dynamic>? videoStream;
|
Map<String, dynamic>? videoStream;
|
||||||
Map<String, dynamic>? audioStream;
|
Map<String, dynamic>? audioStream;
|
||||||
|
|
||||||
for (var stream in streams) {
|
for (final stream in streams) {
|
||||||
final streamType = stream['streamType'] as int?;
|
final streamType = stream['streamType'] as int?;
|
||||||
if (streamType == PlexStreamType.video && videoStream == null) {
|
if (streamType == PlexStreamType.video && videoStream == null) {
|
||||||
videoStream = stream;
|
videoStream = stream;
|
||||||
@@ -2801,13 +2801,13 @@ class PlexClient {
|
|||||||
: (timeline is Map ? timeline : null);
|
: (timeline is Map ? timeline : null);
|
||||||
|
|
||||||
if (op is Map) {
|
if (op is Map) {
|
||||||
if (op['Metadata'] case [Map firstMetadata, ...]) {
|
if (op['Metadata'] case [final Map firstMetadata, ...]) {
|
||||||
if (firstMetadata['Media'] case [Map firstMedia, ...]) {
|
if (firstMetadata['Media'] case [final Map firstMedia, ...]) {
|
||||||
final rawBeginsAt = firstMedia['beginsAt'];
|
final rawBeginsAt = firstMedia['beginsAt'];
|
||||||
|
|
||||||
beginsAt = switch (rawBeginsAt) {
|
beginsAt = switch (rawBeginsAt) {
|
||||||
num n => n.toInt(),
|
final num n => n.toInt(),
|
||||||
String s => int.tryParse(s),
|
final String s => int.tryParse(s),
|
||||||
_ => null,
|
_ => null,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -2878,8 +2878,8 @@ class PlexClient {
|
|||||||
if (firstMedia is Map<String, dynamic>) {
|
if (firstMedia is Map<String, dynamic>) {
|
||||||
final rawBeginsAt = firstMedia['beginsAt'];
|
final rawBeginsAt = firstMedia['beginsAt'];
|
||||||
beginsAt = switch (rawBeginsAt) {
|
beginsAt = switch (rawBeginsAt) {
|
||||||
num n => n.toInt(),
|
final num n => n.toInt(),
|
||||||
String s => int.tryParse(s),
|
final String s => int.tryParse(s),
|
||||||
_ => null,
|
_ => null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -122,7 +122,7 @@ class TrackManager {
|
|||||||
// play() failed — clear the flag immediately since playbackRestart won't fire
|
// play() failed — clear the flag immediately since playbackRestart won't fire
|
||||||
appLogger.w('Resume after subtitle load failed, applying track selection directly', error: e);
|
appLogger.w('Resume after subtitle load failed, applying track selection directly', error: e);
|
||||||
waitingForExternalSubsTrackSelection = false;
|
waitingForExternalSubsTrackSelection = false;
|
||||||
applyTrackSelection();
|
unawaited(applyTrackSelection());
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -418,21 +418,21 @@ class TrackSelectionService {
|
|||||||
final preferredLanguage = getLanguage(preferred);
|
final preferredLanguage = getLanguage(preferred);
|
||||||
|
|
||||||
// Try to match: id, title, and language
|
// Try to match: id, title, and language
|
||||||
for (var track in validTracks) {
|
for (final track in validTracks) {
|
||||||
if (getId(track) == preferredId && getTitle(track) == preferredTitle && getLanguage(track) == preferredLanguage) {
|
if (getId(track) == preferredId && getTitle(track) == preferredTitle && getLanguage(track) == preferredLanguage) {
|
||||||
return track;
|
return track;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try to match: title and language
|
// Try to match: title and language
|
||||||
for (var track in validTracks) {
|
for (final track in validTracks) {
|
||||||
if (getTitle(track) == preferredTitle && getLanguage(track) == preferredLanguage) {
|
if (getTitle(track) == preferredTitle && getLanguage(track) == preferredLanguage) {
|
||||||
return track;
|
return track;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try to match: language only
|
// Try to match: language only
|
||||||
for (var track in validTracks) {
|
for (final track in validTracks) {
|
||||||
if (getLanguage(track) == preferredLanguage) {
|
if (getLanguage(track) == preferredLanguage) {
|
||||||
return track;
|
return track;
|
||||||
}
|
}
|
||||||
@@ -490,7 +490,7 @@ class TrackSelectionService {
|
|||||||
String Function(T) _,
|
String Function(T) _,
|
||||||
String _,
|
String _,
|
||||||
) {
|
) {
|
||||||
for (var track in tracks) {
|
for (final track in tracks) {
|
||||||
final trackLang = getLanguage(track)?.toLowerCase();
|
final trackLang = getLanguage(track)?.toLowerCase();
|
||||||
if (trackLang != null && languageVariations.any((lang) => trackLang.startsWith(lang))) {
|
if (trackLang != null && languageVariations.any((lang) => trackLang.startsWith(lang))) {
|
||||||
return track;
|
return track;
|
||||||
@@ -682,7 +682,7 @@ class TrackSelectionService {
|
|||||||
appLogger.d(
|
appLogger.d(
|
||||||
'Audio: ${selectedAudioTrack.title ?? selectedAudioTrack.language ?? "Track ${selectedAudioTrack.id}"} [${audioResult.priority.name}]',
|
'Audio: ${selectedAudioTrack.title ?? selectedAudioTrack.language ?? "Track ${selectedAudioTrack.id}"} [${audioResult.priority.name}]',
|
||||||
);
|
);
|
||||||
player.selectAudioTrack(selectedAudioTrack);
|
unawaited(player.selectAudioTrack(selectedAudioTrack));
|
||||||
|
|
||||||
// Save to Plex if this was user's navigation preference (Priority 1)
|
// Save to Plex if this was user's navigation preference (Priority 1)
|
||||||
if (audioResult.priority == TrackSelectionPriority.navigation && onAudioTrackChanged != null) {
|
if (audioResult.priority == TrackSelectionPriority.navigation && onAudioTrackChanged != null) {
|
||||||
@@ -697,7 +697,7 @@ class TrackSelectionService {
|
|||||||
? 'OFF'
|
? 'OFF'
|
||||||
: (selectedSubtitleTrack.title ?? selectedSubtitleTrack.language ?? 'Track ${selectedSubtitleTrack.id}');
|
: (selectedSubtitleTrack.title ?? selectedSubtitleTrack.language ?? 'Track ${selectedSubtitleTrack.id}');
|
||||||
appLogger.d('Subtitle: $subtitleName [${subtitleResult.priority.name}]');
|
appLogger.d('Subtitle: $subtitleName [${subtitleResult.priority.name}]');
|
||||||
player.selectSubtitleTrack(selectedSubtitleTrack);
|
unawaited(player.selectSubtitleTrack(selectedSubtitleTrack));
|
||||||
|
|
||||||
// Save to Plex if this was user's navigation preference (Priority 1)
|
// Save to Plex if this was user's navigation preference (Priority 1)
|
||||||
if (subtitleResult.priority == TrackSelectionPriority.navigation && onSubtitleTrackChanged != null) {
|
if (subtitleResult.priority == TrackSelectionPriority.navigation && onSubtitleTrackChanged != null) {
|
||||||
@@ -714,13 +714,13 @@ class TrackSelectionService {
|
|||||||
appLogger.d(
|
appLogger.d(
|
||||||
'Secondary subtitle: ${secondaryMatch.title ?? secondaryMatch.language ?? "Track ${secondaryMatch.id}"}',
|
'Secondary subtitle: ${secondaryMatch.title ?? secondaryMatch.language ?? "Track ${secondaryMatch.id}"}',
|
||||||
);
|
);
|
||||||
player.selectSecondarySubtitleTrack(secondaryMatch);
|
unawaited(player.selectSecondarySubtitleTrack(secondaryMatch));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply default playback speed from settings
|
// Apply default playback speed from settings
|
||||||
if (defaultPlaybackSpeed != null && defaultPlaybackSpeed != 1.0) {
|
if (defaultPlaybackSpeed != null && defaultPlaybackSpeed != 1.0) {
|
||||||
player.setRate(defaultPlaybackSpeed);
|
unawaited(player.setRate(defaultPlaybackSpeed));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -108,6 +108,7 @@ ThemeData monoTheme({required bool dark, bool oled = false}) {
|
|||||||
thumbShape: const HandleThumbShape(),
|
thumbShape: const HandleThumbShape(),
|
||||||
trackShape: const GappedTrackShape(),
|
trackShape: const GappedTrackShape(),
|
||||||
tickMarkShape: const RoundSliderTickMarkShape(tickMarkRadius: 2),
|
tickMarkShape: const RoundSliderTickMarkShape(tickMarkRadius: 2),
|
||||||
|
// ignore: deprecated_member_use — opting into the 2024 slider appearance until the default flips
|
||||||
year2023: false,
|
year2023: false,
|
||||||
),
|
),
|
||||||
dividerTheme: DividerThemeData(space: 0, thickness: 1, color: c.outline),
|
dividerTheme: DividerThemeData(space: 0, thickness: 1, color: c.outline),
|
||||||
|
|||||||
@@ -3,8 +3,8 @@
|
|||||||
/// to handle Plex API responses where numeric fields may arrive as strings
|
/// to handle Plex API responses where numeric fields may arrive as strings
|
||||||
/// (XML-to-JSON conversion).
|
/// (XML-to-JSON conversion).
|
||||||
int? flexibleInt(Object? v) => switch (v) {
|
int? flexibleInt(Object? v) => switch (v) {
|
||||||
num n => n.toInt(),
|
final num n => n.toInt(),
|
||||||
String s => int.tryParse(s),
|
final String s => int.tryParse(s),
|
||||||
_ => null,
|
_ => null,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -12,16 +12,16 @@ int? flexibleInt(Object? v) => switch (v) {
|
|||||||
/// Returns `false` for `null` or unrecognised values.
|
/// Returns `false` for `null` or unrecognised values.
|
||||||
/// Handles Plex API responses where boolean fields may arrive as integers.
|
/// Handles Plex API responses where boolean fields may arrive as integers.
|
||||||
bool flexibleBool(Object? v) => switch (v) {
|
bool flexibleBool(Object? v) => switch (v) {
|
||||||
bool b => b,
|
final bool b => b,
|
||||||
int n => n == 1,
|
final int n => n == 1,
|
||||||
String s => s == '1',
|
final String s => s == '1',
|
||||||
_ => false,
|
_ => false,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Parse a value that may be [double], [num], or [String] to [double].
|
/// Parse a value that may be [double], [num], or [String] to [double].
|
||||||
double? flexibleDouble(Object? v) => switch (v) {
|
double? flexibleDouble(Object? v) => switch (v) {
|
||||||
num n => n.toDouble(),
|
final num n => n.toDouble(),
|
||||||
String s => double.tryParse(s),
|
final String s => double.tryParse(s),
|
||||||
_ => null,
|
_ => null,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -36,6 +36,6 @@ Object? readStringField(Map json, String key) => json[key]?.toString();
|
|||||||
/// Returns `null` when the value is `null`.
|
/// Returns `null` when the value is `null`.
|
||||||
List<dynamic>? flexibleList(Object? v) => switch (v) {
|
List<dynamic>? flexibleList(Object? v) => switch (v) {
|
||||||
null => null,
|
null => null,
|
||||||
List l => l,
|
final List l => l,
|
||||||
_ => <dynamic>[v],
|
_ => <dynamic>[v],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
import '../models/livetv_channel.dart';
|
import '../models/livetv_channel.dart';
|
||||||
@@ -43,5 +45,5 @@ Future<void> navigateToLiveTv(
|
|||||||
reverseTransitionDuration: Duration.zero,
|
reverseTransitionDuration: Duration.zero,
|
||||||
);
|
);
|
||||||
|
|
||||||
navigator.push<bool>(route);
|
unawaited(navigator.push<bool>(route));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -348,10 +348,10 @@ class WatchTogetherProvider with ChangeNotifier {
|
|||||||
_syncManager?.announceLeave();
|
_syncManager?.announceLeave();
|
||||||
|
|
||||||
// Clean up subscriptions
|
// Clean up subscriptions
|
||||||
_peerConnectedSubscription?.cancel();
|
unawaited(_peerConnectedSubscription?.cancel());
|
||||||
_peerDisconnectedSubscription?.cancel();
|
unawaited(_peerDisconnectedSubscription?.cancel());
|
||||||
_messageSubscription?.cancel();
|
unawaited(_messageSubscription?.cancel());
|
||||||
_errorSubscription?.cancel();
|
unawaited(_errorSubscription?.cancel());
|
||||||
|
|
||||||
_peerConnectedSubscription = null;
|
_peerConnectedSubscription = null;
|
||||||
_peerDisconnectedSubscription = null;
|
_peerDisconnectedSubscription = null;
|
||||||
|
|||||||
@@ -432,7 +432,7 @@ class WatchTogetherPeerService with KeepaliveMixin {
|
|||||||
_reconnectTimer = null;
|
_reconnectTimer = null;
|
||||||
stopKeepalive();
|
stopKeepalive();
|
||||||
|
|
||||||
_channelSubscription?.cancel();
|
unawaited(_channelSubscription?.cancel());
|
||||||
_channelSubscription = null;
|
_channelSubscription = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -276,7 +276,7 @@ class _SessionMenuSheet extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (confirmed) {
|
if (confirmed) {
|
||||||
provider.leaveSession();
|
unawaited(provider.leaveSession());
|
||||||
onLeaveSession?.call();
|
onLeaveSession?.call();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -116,7 +116,7 @@ class _FocusableListTileState extends State<FocusableListTile> with FocusableTil
|
|||||||
final textColor = needsContrastSwap ? Theme.of(context).colorScheme.onError : widget.textColor;
|
final textColor = needsContrastSwap ? Theme.of(context).colorScheme.onError : widget.textColor;
|
||||||
final iconColor = needsContrastSwap ? Theme.of(context).colorScheme.onError : widget.iconColor;
|
final iconColor = needsContrastSwap ? Theme.of(context).colorScheme.onError : widget.iconColor;
|
||||||
|
|
||||||
Widget tile = MouseRegion(
|
final Widget tile = MouseRegion(
|
||||||
onEnter: widget.hoverColor != null ? (_) => setState(() => _isHoveredOrFocused = true) : null,
|
onEnter: widget.hoverColor != null ? (_) => setState(() => _isHoveredOrFocused = true) : null,
|
||||||
onExit: widget.hoverColor != null ? (_) => setState(() => _isHoveredOrFocused = false) : null,
|
onExit: widget.hoverColor != null ? (_) => setState(() => _isHoveredOrFocused = false) : null,
|
||||||
child: ListTile(
|
child: ListTile(
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ class _HotKeyRecorderState extends State<HotKeyRecorder> {
|
|||||||
final PhysicalKeyboardKey key = keyEvent.physicalKey;
|
final PhysicalKeyboardKey key = keyEvent.physicalKey;
|
||||||
|
|
||||||
// Detect which modifiers are currently held down, excluding the primary key
|
// Detect which modifiers are currently held down, excluding the primary key
|
||||||
List<HotKeyModifier> modifiers = HotKeyModifier.values
|
final List<HotKeyModifier> modifiers = HotKeyModifier.values
|
||||||
.where((m) => m.physicalKeys.any(physicalKeysPressed.contains))
|
.where((m) => m.physicalKeys.any(physicalKeysPressed.contains))
|
||||||
.where((m) => !m.physicalKeys.contains(key))
|
.where((m) => !m.physicalKeys.contains(key))
|
||||||
.toList();
|
.toList();
|
||||||
|
|||||||
@@ -143,7 +143,7 @@ class _ServerActivitiesButtonState extends State<ServerActivitiesButton> {
|
|||||||
_pollTimer?.cancel();
|
_pollTimer?.cancel();
|
||||||
_pollTimer = null;
|
_pollTimer = null;
|
||||||
_panelNotifier.value = _PanelData.loading;
|
_panelNotifier.value = _PanelData.loading;
|
||||||
_fetchActivities();
|
unawaited(_fetchActivities());
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildOverlay(BuildContext overlayContext, {required double right, required double top}) {
|
Widget _buildOverlay(BuildContext overlayContext, {required double right, required double top}) {
|
||||||
|
|||||||
@@ -26,8 +26,8 @@ class TrackFilterHelper {
|
|||||||
|
|
||||||
static bool _isAllowedTrack<T>(T track) {
|
static bool _isAllowedTrack<T>(T track) {
|
||||||
final id = switch (track) {
|
final id = switch (track) {
|
||||||
AudioTrack t => t.id,
|
final AudioTrack t => t.id,
|
||||||
SubtitleTrack t => t.id,
|
final SubtitleTrack t => t.id,
|
||||||
_ => '',
|
_ => '',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -559,7 +559,7 @@ class _VideoSettingsSheetState extends State<VideoSettingsSheet> {
|
|||||||
title: Text(label, style: TextStyle(color: isSelected ? primary : null)),
|
title: Text(label, style: TextStyle(color: isSelected ? primary : null)),
|
||||||
trailing: isSelected ? AppIcon(Symbols.check_rounded, fill: 1, color: primary) : null,
|
trailing: isSelected ? AppIcon(Symbols.check_rounded, fill: 1, color: primary) : null,
|
||||||
onTap: () async {
|
onTap: () async {
|
||||||
widget.player.setRate(speed);
|
await widget.player.setRate(speed);
|
||||||
// Save as default playback speed
|
// Save as default playback speed
|
||||||
final settings = await SettingsService.getInstance();
|
final settings = await SettingsService.getInstance();
|
||||||
await settings.write(SettingsService.defaultPlaybackSpeed, speed);
|
await settings.write(SettingsService.defaultPlaybackSpeed, speed);
|
||||||
|
|||||||
@@ -669,9 +669,11 @@ class _PlexVideoControlsState extends State<PlexVideoControls> with WindowListen
|
|||||||
|
|
||||||
// Apply rotation lock setting
|
// Apply rotation lock setting
|
||||||
if (_isRotationLocked) {
|
if (_isRotationLocked) {
|
||||||
SystemChrome.setPreferredOrientations([DeviceOrientation.landscapeLeft, DeviceOrientation.landscapeRight]);
|
unawaited(
|
||||||
|
SystemChrome.setPreferredOrientations([DeviceOrientation.landscapeLeft, DeviceOrientation.landscapeRight]),
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
SystemChrome.setPreferredOrientations(DeviceOrientation.values);
|
unawaited(SystemChrome.setPreferredOrientations(DeviceOrientation.values));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -994,10 +996,10 @@ class _PlexVideoControlsState extends State<PlexVideoControls> with WindowListen
|
|||||||
|
|
||||||
if (_isRotationLocked) {
|
if (_isRotationLocked) {
|
||||||
// Locked: Allow landscape orientations only
|
// Locked: Allow landscape orientations only
|
||||||
SystemChrome.setPreferredOrientations([DeviceOrientation.landscapeLeft, DeviceOrientation.landscapeRight]);
|
await SystemChrome.setPreferredOrientations([DeviceOrientation.landscapeLeft, DeviceOrientation.landscapeRight]);
|
||||||
} else {
|
} else {
|
||||||
// Unlocked: Allow all orientations including portrait
|
// Unlocked: Allow all orientations including portrait
|
||||||
SystemChrome.setPreferredOrientations(DeviceOrientation.values);
|
await SystemChrome.setPreferredOrientations(DeviceOrientation.values);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1139,7 +1141,7 @@ class _PlexVideoControlsState extends State<PlexVideoControls> with WindowListen
|
|||||||
if (metadataJson != null) {
|
if (metadataJson != null) {
|
||||||
// Parse chapters
|
// Parse chapters
|
||||||
if (metadataJson['Chapter'] != null) {
|
if (metadataJson['Chapter'] != null) {
|
||||||
for (var chapter in metadataJson['Chapter'] as List) {
|
for (final chapter in metadataJson['Chapter'] as List) {
|
||||||
chapters.add(
|
chapters.add(
|
||||||
PlexChapter(
|
PlexChapter(
|
||||||
id: chapter['id'] as int,
|
id: chapter['id'] as int,
|
||||||
@@ -1155,7 +1157,7 @@ class _PlexVideoControlsState extends State<PlexVideoControls> with WindowListen
|
|||||||
|
|
||||||
// Parse markers
|
// Parse markers
|
||||||
if (metadataJson['Marker'] != null) {
|
if (metadataJson['Marker'] != null) {
|
||||||
for (var marker in metadataJson['Marker'] as List) {
|
for (final marker in metadataJson['Marker'] as List) {
|
||||||
markers.add(
|
markers.add(
|
||||||
PlexMarker(
|
PlexMarker(
|
||||||
id: marker['id'] as int,
|
id: marker['id'] as int,
|
||||||
@@ -2611,19 +2613,21 @@ class _PlexVideoControlsState extends State<PlexVideoControls> with WindowListen
|
|||||||
// Navigate to new player screen with the updated selection
|
// Navigate to new player screen with the updated selection
|
||||||
// Use PageRouteBuilder with zero-duration transitions to prevent orientation reset
|
// Use PageRouteBuilder with zero-duration transitions to prevent orientation reset
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
Navigator.pushReplacement(
|
unawaited(
|
||||||
context,
|
Navigator.pushReplacement(
|
||||||
PageRouteBuilder<bool>(
|
context,
|
||||||
pageBuilder: (context, animation, secondaryAnimation) => VideoPlayerScreen(
|
PageRouteBuilder<bool>(
|
||||||
metadata: widget.metadata.copyWith(viewOffset: currentPosition.inMilliseconds),
|
pageBuilder: (context, animation, secondaryAnimation) => VideoPlayerScreen(
|
||||||
selectedMediaIndex: effectiveMediaIndex,
|
metadata: widget.metadata.copyWith(viewOffset: currentPosition.inMilliseconds),
|
||||||
selectedQualityPreset: effectivePreset,
|
selectedMediaIndex: effectiveMediaIndex,
|
||||||
selectedAudioStreamId: effectiveAudioStreamId,
|
selectedQualityPreset: effectivePreset,
|
||||||
reusedSessionIdentifier: sessionId,
|
selectedAudioStreamId: effectiveAudioStreamId,
|
||||||
reusedTranscodeSessionId: transcodeSessionId,
|
reusedSessionIdentifier: sessionId,
|
||||||
|
reusedTranscodeSessionId: transcodeSessionId,
|
||||||
|
),
|
||||||
|
transitionDuration: Duration.zero,
|
||||||
|
reverseTransitionDuration: Duration.zero,
|
||||||
),
|
),
|
||||||
transitionDuration: Duration.zero,
|
|
||||||
reverseTransitionDuration: Duration.zero,
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ class VideoControlsHeader extends StatelessWidget {
|
|||||||
final seriesName = metadata.grandparentTitle ?? metadata.title!;
|
final seriesName = metadata.grandparentTitle ?? metadata.title!;
|
||||||
final hasEpisodeInfo = metadata.parentIndex != null && metadata.index != null;
|
final hasEpisodeInfo = metadata.parentIndex != null && metadata.index != null;
|
||||||
|
|
||||||
List<String> parts = [seriesName];
|
final List<String> parts = [seriesName];
|
||||||
|
|
||||||
if (hasEpisodeInfo) {
|
if (hasEpisodeInfo) {
|
||||||
parts.add('S${metadata.parentIndex}E${metadata.index}');
|
parts.add('S${metadata.parentIndex}E${metadata.index}');
|
||||||
@@ -83,7 +83,7 @@ class VideoControlsHeader extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildMultiLineTitle() {
|
Widget _buildMultiLineTitle() {
|
||||||
List<String> secondLineParts = [];
|
final List<String> secondLineParts = [];
|
||||||
|
|
||||||
if (metadata.parentIndex != null && metadata.index != null) {
|
if (metadata.parentIndex != null && metadata.index != null) {
|
||||||
secondLineParts.add('S${metadata.parentIndex}');
|
secondLineParts.add('S${metadata.parentIndex}');
|
||||||
|
|||||||
@@ -87,7 +87,7 @@ class _VolumeControlState extends State<VolumeControl> {
|
|||||||
Future<void> _adjustVolume(double delta) async {
|
Future<void> _adjustVolume(double delta) async {
|
||||||
final currentVolume = widget.player.state.volume;
|
final currentVolume = widget.player.state.volume;
|
||||||
final newVolume = (currentVolume + delta).clamp(0.0, _maxVolume.toDouble());
|
final newVolume = (currentVolume + delta).clamp(0.0, _maxVolume.toDouble());
|
||||||
widget.player.setVolume(newVolume);
|
await widget.player.setVolume(newVolume);
|
||||||
final settings = await SettingsService.getInstance();
|
final settings = await SettingsService.getInstance();
|
||||||
await settings.write(SettingsService.volume, newVolume);
|
await settings.write(SettingsService.volume, newVolume);
|
||||||
}
|
}
|
||||||
@@ -168,7 +168,7 @@ class _VolumeControlState extends State<VolumeControl> {
|
|||||||
),
|
),
|
||||||
onPressed: () async {
|
onPressed: () async {
|
||||||
final newVolume = isMuted ? 100.0 : 0.0;
|
final newVolume = isMuted ? 100.0 : 0.0;
|
||||||
widget.player.setVolume(newVolume);
|
await widget.player.setVolume(newVolume);
|
||||||
final settings = await SettingsService.getInstance();
|
final settings = await SettingsService.getInstance();
|
||||||
await settings.write(SettingsService.volume, newVolume);
|
await settings.write(SettingsService.volume, newVolume);
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user