refactor: share low-risk UI and tracker helpers
This commit is contained in:
@@ -6,17 +6,16 @@ import 'package:flutter/services.dart'
|
||||
show HardwareKeyboard, KeyDownEvent, KeyUpEvent, LogicalKeyboardKey, SystemNavigator;
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
import 'package:window_manager/window_manager.dart';
|
||||
import '../i18n/strings.g.dart';
|
||||
import '../services/update_service.dart';
|
||||
import '../utils/app_logger.dart';
|
||||
import '../widgets/auth_error_banner.dart';
|
||||
import '../widgets/dialog_action_button.dart';
|
||||
import '../utils/dialogs.dart';
|
||||
import '../utils/provider_extensions.dart';
|
||||
import '../utils/platform_detector.dart';
|
||||
import '../utils/snackbar_helper.dart';
|
||||
import '../utils/update_dialog.dart';
|
||||
import '../utils/video_player_navigation.dart';
|
||||
import '../main.dart';
|
||||
import '../mixins/refreshable.dart';
|
||||
@@ -459,59 +458,20 @@ class _MainScreenState extends State<MainScreen> with RouteAware, WindowListener
|
||||
final updateInfo = await UpdateService.checkForUpdatesOnStartup();
|
||||
|
||||
if (updateInfo != null && updateInfo['hasUpdate'] == true && mounted) {
|
||||
_showUpdateDialog(updateInfo);
|
||||
await _showUpdateDialog(updateInfo);
|
||||
}
|
||||
} catch (e) {
|
||||
appLogger.e('Error checking for updates', error: e);
|
||||
}
|
||||
}
|
||||
|
||||
void _showUpdateDialog(Map<String, dynamic> updateInfo) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (BuildContext dialogContext) {
|
||||
return AlertDialog(
|
||||
title: Text(t.update.available),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
t.update.versionAvailable(version: updateInfo['latestVersion']),
|
||||
style: Theme.of(dialogContext).textTheme.titleMedium,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
t.update.currentVersion(version: updateInfo['currentVersion']),
|
||||
style: Theme.of(dialogContext).textTheme.bodySmall,
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
DialogActionButton(onPressed: () => Navigator.pop(dialogContext), label: t.common.later),
|
||||
DialogActionButton(
|
||||
onPressed: () async {
|
||||
await UpdateService.skipVersion(updateInfo['latestVersion']);
|
||||
if (dialogContext.mounted) Navigator.pop(dialogContext);
|
||||
},
|
||||
label: t.update.skipVersion,
|
||||
),
|
||||
DialogActionButton(
|
||||
onPressed: () async {
|
||||
final url = Uri.parse(updateInfo['releaseUrl']);
|
||||
if (await canLaunchUrl(url)) {
|
||||
await launchUrl(url, mode: LaunchMode.externalApplication);
|
||||
}
|
||||
if (dialogContext.mounted) Navigator.pop(dialogContext);
|
||||
},
|
||||
label: t.update.viewRelease,
|
||||
isPrimary: true,
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
Future<void> _showUpdateDialog(Map<String, dynamic> updateInfo) => showUpdateAvailableDialog(
|
||||
context,
|
||||
updateInfo,
|
||||
title: t.update.available,
|
||||
dismissLabel: t.common.later,
|
||||
showSkipVersion: true,
|
||||
);
|
||||
|
||||
/// Set up the Watch Together navigation callback for guests
|
||||
void _setupWatchTogetherCallback() {
|
||||
|
||||
@@ -31,6 +31,7 @@ import '../../services/update_service.dart';
|
||||
import '../../utils/dialogs.dart';
|
||||
import '../../utils/snackbar_helper.dart';
|
||||
import '../../utils/platform_detector.dart';
|
||||
import '../../utils/update_dialog.dart';
|
||||
import '../../widgets/desktop_app_bar.dart';
|
||||
import '../../widgets/dialog_action_button.dart';
|
||||
import '../../widgets/settings_section.dart';
|
||||
@@ -879,44 +880,10 @@ class _SettingsScreenState extends State<SettingsScreen> with FocusableTab {
|
||||
}
|
||||
|
||||
void _showUpdateDialog() {
|
||||
if (_updateInfo == null) return;
|
||||
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (BuildContext context) {
|
||||
return AlertDialog(
|
||||
title: Text(t.settings.updateAvailable),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
t.update.versionAvailable(version: _updateInfo!['latestVersion']),
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
t.update.currentVersion(version: _updateInfo!['currentVersion']),
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
DialogActionButton(onPressed: () => Navigator.pop(context), label: t.common.close),
|
||||
DialogActionButton(
|
||||
onPressed: () async {
|
||||
final url = Uri.parse(_updateInfo!['releaseUrl']);
|
||||
if (await canLaunchUrl(url)) {
|
||||
await launchUrl(url, mode: LaunchMode.externalApplication);
|
||||
}
|
||||
if (context.mounted) Navigator.pop(context);
|
||||
},
|
||||
label: t.update.viewRelease,
|
||||
isPrimary: true,
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
final updateInfo = _updateInfo;
|
||||
if (updateInfo == null) return;
|
||||
unawaited(
|
||||
showUpdateAvailableDialog(context, updateInfo, title: t.settings.updateAvailable, dismissLabel: t.common.close),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,16 +33,6 @@ import '../utils/obfuscation_utils.dart';
|
||||
/// `com.plexapp.agents.none://` and new-style `tv.plex.agents.none://`.
|
||||
const _unmatchedAgentMarker = 'agents.none://';
|
||||
|
||||
List<String>? _tagsFromJsonList(List? json) =>
|
||||
json?.cast<Map<String, dynamic>>().map((e) => e['tag'] as String).toList();
|
||||
|
||||
bool? _flexibleBoolNullable(Object? v) => switch (v) {
|
||||
final bool b => b,
|
||||
final int n => n == 1,
|
||||
final String s => s == '1',
|
||||
_ => null,
|
||||
};
|
||||
|
||||
class PlexRoleDto {
|
||||
final int? id;
|
||||
final String? filter;
|
||||
@@ -103,8 +93,8 @@ class PlexMediaVersionDto {
|
||||
height: flexibleInt(json['height']),
|
||||
container: json['container']?.toString(),
|
||||
partKey: partKey,
|
||||
accessible: _flexibleBoolNullable(part?['accessible']),
|
||||
exists: _flexibleBoolNullable(part?['exists']),
|
||||
accessible: flexibleBoolNullable(part?['accessible']),
|
||||
exists: flexibleBoolNullable(part?['exists']),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -516,15 +506,15 @@ class PlexMetadataDto {
|
||||
childCount: flexibleInt(json['childCount']),
|
||||
role: roleList,
|
||||
mediaVersions: mediaList,
|
||||
genre: _tagsFromJsonList(json['Genre'] as List?),
|
||||
director: _tagsFromJsonList(json['Director'] as List?),
|
||||
writer: _tagsFromJsonList(json['Writer'] as List?),
|
||||
producer: _tagsFromJsonList(json['Producer'] as List?),
|
||||
country: _tagsFromJsonList(json['Country'] as List?),
|
||||
collection: _tagsFromJsonList(json['Collection'] as List?),
|
||||
label: _tagsFromJsonList(json['Label'] as List?),
|
||||
style: _tagsFromJsonList(json['Style'] as List?),
|
||||
mood: _tagsFromJsonList(json['Mood'] as List?),
|
||||
genre: stringListFromRaw(json['Genre'], mapKey: 'tag'),
|
||||
director: stringListFromRaw(json['Director'], mapKey: 'tag'),
|
||||
writer: stringListFromRaw(json['Writer'], mapKey: 'tag'),
|
||||
producer: stringListFromRaw(json['Producer'], mapKey: 'tag'),
|
||||
country: stringListFromRaw(json['Country'], mapKey: 'tag'),
|
||||
collection: stringListFromRaw(json['Collection'], mapKey: 'tag'),
|
||||
label: stringListFromRaw(json['Label'], mapKey: 'tag'),
|
||||
style: stringListFromRaw(json['Style'], mapKey: 'tag'),
|
||||
mood: stringListFromRaw(json['Mood'], mapKey: 'tag'),
|
||||
audioLanguage: json['audioLanguage'] as String?,
|
||||
subtitleLanguage: json['subtitleLanguage'] as String?,
|
||||
subtitleMode: flexibleInt(json['subtitleMode']),
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import '../oauth_proxy_client.dart';
|
||||
import '../tracker_session_utils.dart';
|
||||
|
||||
/// Immutable AniList OAuth session.
|
||||
///
|
||||
@@ -14,7 +13,7 @@ class AnilistSession {
|
||||
|
||||
const AnilistSession({required this.accessToken, required this.expiresAt, required this.createdAt, this.username});
|
||||
|
||||
bool get isExpired => DateTime.now().millisecondsSinceEpoch ~/ 1000 >= expiresAt;
|
||||
bool get isExpired => isTrackerTokenExpired(expiresAt);
|
||||
|
||||
AnilistSession copyWith({String? accessToken, int? expiresAt, String? username, int? createdAt}) {
|
||||
return AnilistSession(
|
||||
@@ -43,11 +42,11 @@ class AnilistSession {
|
||||
/// and have no refresh; when the proxy doesn't echo an explicit expiry we
|
||||
/// default to the documented year.
|
||||
factory AnilistSession.fromProxyResult(OAuthProxyResult r) {
|
||||
final createdAt = DateTime.now().millisecondsSinceEpoch ~/ 1000;
|
||||
final createdAt = trackerSessionNowEpochSeconds();
|
||||
final expiresIn = r.expiresIn ?? 365 * 24 * 60 * 60;
|
||||
return AnilistSession(accessToken: r.accessToken, expiresAt: createdAt + expiresIn, createdAt: createdAt);
|
||||
}
|
||||
|
||||
String encode() => json.encode(toJson());
|
||||
static AnilistSession decode(String raw) => AnilistSession.fromJson(json.decode(raw) as Map<String, dynamic>);
|
||||
String encode() => encodeTrackerSessionJson(toJson());
|
||||
static AnilistSession decode(String raw) => decodeTrackerSessionJson(raw, AnilistSession.fromJson);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import '../oauth_proxy_client.dart';
|
||||
import '../tracker_session_utils.dart';
|
||||
|
||||
/// Immutable MyAnimeList OAuth session.
|
||||
///
|
||||
@@ -21,8 +20,8 @@ class MalSession {
|
||||
this.username,
|
||||
});
|
||||
|
||||
bool get isExpired => DateTime.now().millisecondsSinceEpoch ~/ 1000 >= expiresAt;
|
||||
bool get needsRefresh => DateTime.now().millisecondsSinceEpoch ~/ 1000 >= expiresAt - 300;
|
||||
bool get isExpired => isTrackerTokenExpired(expiresAt);
|
||||
bool get needsRefresh => trackerTokenNeedsRefresh(expiresAt);
|
||||
|
||||
MalSession copyWith({String? accessToken, String? refreshToken, int? expiresAt, String? username, int? createdAt}) {
|
||||
return MalSession(
|
||||
@@ -52,7 +51,7 @@ class MalSession {
|
||||
|
||||
/// Build a session from MAL's `/oauth2/token` response.
|
||||
factory MalSession.fromTokenResponse(Map<String, dynamic> json) {
|
||||
final createdAt = DateTime.now().millisecondsSinceEpoch ~/ 1000;
|
||||
final createdAt = trackerSessionNowEpochSeconds();
|
||||
final expiresIn = (json['expires_in'] as num).toInt();
|
||||
return MalSession(
|
||||
accessToken: json['access_token'] as String,
|
||||
@@ -65,7 +64,7 @@ class MalSession {
|
||||
/// Build a session from an OAuth-proxy result. MAL's refresh_token is
|
||||
/// required for the 31-day refresh loop.
|
||||
factory MalSession.fromProxyResult(OAuthProxyResult r) {
|
||||
final createdAt = DateTime.now().millisecondsSinceEpoch ~/ 1000;
|
||||
final createdAt = trackerSessionNowEpochSeconds();
|
||||
final expiresIn = r.expiresIn ?? 31 * 24 * 60 * 60;
|
||||
return MalSession(
|
||||
accessToken: r.accessToken,
|
||||
@@ -75,6 +74,6 @@ class MalSession {
|
||||
);
|
||||
}
|
||||
|
||||
String encode() => json.encode(toJson());
|
||||
static MalSession decode(String raw) => MalSession.fromJson(json.decode(raw) as Map<String, dynamic>);
|
||||
String encode() => encodeTrackerSessionJson(toJson());
|
||||
static MalSession decode(String raw) => decodeTrackerSessionJson(raw, MalSession.fromJson);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import 'dart:convert';
|
||||
import '../tracker_session_utils.dart';
|
||||
|
||||
/// Immutable Simkl OAuth session.
|
||||
///
|
||||
@@ -27,11 +27,9 @@ class SimklSession {
|
||||
|
||||
/// Build a session from Simkl's device-code `/oauth/pin/<code>` response.
|
||||
/// Simkl doesn't expose a creation timestamp so we stamp "now".
|
||||
factory SimklSession.fromTokenResponse(Map<String, dynamic> json) => SimklSession(
|
||||
accessToken: json['access_token'] as String,
|
||||
createdAt: DateTime.now().millisecondsSinceEpoch ~/ 1000,
|
||||
);
|
||||
factory SimklSession.fromTokenResponse(Map<String, dynamic> json) =>
|
||||
SimklSession(accessToken: json['access_token'] as String, createdAt: trackerSessionNowEpochSeconds());
|
||||
|
||||
String encode() => json.encode(toJson());
|
||||
static SimklSession decode(String raw) => SimklSession.fromJson(json.decode(raw) as Map<String, dynamic>);
|
||||
String encode() => encodeTrackerSessionJson(toJson());
|
||||
static SimklSession decode(String raw) => decodeTrackerSessionJson(raw, SimklSession.fromJson);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import 'dart:convert' as convert;
|
||||
|
||||
/// Current epoch time in seconds, matching tracker OAuth expiry fields.
|
||||
int trackerSessionNowEpochSeconds() => DateTime.now().millisecondsSinceEpoch ~/ 1000;
|
||||
|
||||
bool isTrackerTokenExpired(int expiresAt, {int? nowSeconds}) =>
|
||||
(nowSeconds ?? trackerSessionNowEpochSeconds()) >= expiresAt;
|
||||
|
||||
bool trackerTokenNeedsRefresh(int expiresAt, {int refreshWindowSeconds = 300, int? nowSeconds}) =>
|
||||
(nowSeconds ?? trackerSessionNowEpochSeconds()) >= expiresAt - refreshWindowSeconds;
|
||||
|
||||
String encodeTrackerSessionJson(Map<String, dynamic> value) => convert.json.encode(value);
|
||||
|
||||
T decodeTrackerSessionJson<T>(String raw, T Function(Map<String, dynamic> json) fromJson) {
|
||||
return fromJson(convert.json.decode(raw) as Map<String, dynamic>);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import 'dart:convert';
|
||||
import '../trackers/tracker_session_utils.dart';
|
||||
|
||||
/// Immutable Trakt OAuth session.
|
||||
///
|
||||
@@ -30,10 +30,10 @@ class TraktSession {
|
||||
});
|
||||
|
||||
/// Whether the access token has already expired.
|
||||
bool get isExpired => DateTime.now().millisecondsSinceEpoch ~/ 1000 >= expiresAt;
|
||||
bool get isExpired => isTrackerTokenExpired(expiresAt);
|
||||
|
||||
/// Whether the access token will expire in the next 5 minutes.
|
||||
bool get needsRefresh => DateTime.now().millisecondsSinceEpoch ~/ 1000 >= expiresAt - 300;
|
||||
bool get needsRefresh => trackerTokenNeedsRefresh(expiresAt);
|
||||
|
||||
TraktSession copyWith({
|
||||
String? accessToken,
|
||||
@@ -76,7 +76,7 @@ class TraktSession {
|
||||
/// Build a session from Trakt's `/oauth/token` or `/oauth/device/token` response,
|
||||
/// which uses `expires_in` (relative seconds) rather than `expires_at`.
|
||||
factory TraktSession.fromTokenResponse(Map<String, dynamic> json) {
|
||||
final createdAt = (json['created_at'] as num?)?.toInt() ?? DateTime.now().millisecondsSinceEpoch ~/ 1000;
|
||||
final createdAt = (json['created_at'] as num?)?.toInt() ?? trackerSessionNowEpochSeconds();
|
||||
final expiresIn = (json['expires_in'] as num).toInt();
|
||||
return TraktSession(
|
||||
accessToken: json['access_token'] as String,
|
||||
@@ -87,6 +87,6 @@ class TraktSession {
|
||||
);
|
||||
}
|
||||
|
||||
String encode() => json.encode(toJson());
|
||||
static TraktSession decode(String raw) => TraktSession.fromJson(json.decode(raw) as Map<String, dynamic>);
|
||||
String encode() => encodeTrackerSessionJson(toJson());
|
||||
static TraktSession decode(String raw) => decodeTrackerSessionJson(raw, TraktSession.fromJson);
|
||||
}
|
||||
|
||||
@@ -18,6 +18,15 @@ bool flexibleBool(Object? v) => switch (v) {
|
||||
_ => false,
|
||||
};
|
||||
|
||||
/// Parse a value that may be [bool], [int] (0/1), or [String] ('1') to [bool].
|
||||
/// Returns `null` for `null` or unrecognised values.
|
||||
bool? flexibleBoolNullable(Object? v) => switch (v) {
|
||||
final bool b => b,
|
||||
final int n => n == 1,
|
||||
final String s => s == '1',
|
||||
_ => null,
|
||||
};
|
||||
|
||||
/// Parse a value that may be [double], [num], or [String] to [double].
|
||||
double? flexibleDouble(Object? v) => switch (v) {
|
||||
final num n => n.toDouble(),
|
||||
|
||||
@@ -35,6 +35,21 @@ void scrollToCurrentItem(ScrollController controller, GlobalKey firstItemKey, in
|
||||
});
|
||||
}
|
||||
|
||||
/// Owns the boilerplate for one-time initial scrolling in selectable lists.
|
||||
class InitialItemScrollController {
|
||||
final GlobalKey firstItemKey = GlobalKey();
|
||||
final ScrollController controller = ScrollController();
|
||||
bool _didInitialScroll = false;
|
||||
|
||||
void maybeScrollTo(int? selectedIndex) {
|
||||
if (_didInitialScroll || selectedIndex == null || selectedIndex <= 0) return;
|
||||
_didInitialScroll = true;
|
||||
scrollToCurrentItem(controller, firstItemKey, selectedIndex);
|
||||
}
|
||||
|
||||
void dispose() => controller.dispose();
|
||||
}
|
||||
|
||||
/// Scroll a horizontal list to center the item at the given index.
|
||||
///
|
||||
/// Assumes items are laid out with [leadingPadding] before the first item,
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
import '../i18n/strings.g.dart';
|
||||
import '../services/update_service.dart';
|
||||
import '../widgets/dialog_action_button.dart';
|
||||
|
||||
Future<void> showUpdateAvailableDialog(
|
||||
BuildContext context,
|
||||
Map<String, dynamic> updateInfo, {
|
||||
required String title,
|
||||
required String dismissLabel,
|
||||
bool showSkipVersion = false,
|
||||
}) {
|
||||
return showDialog<void>(
|
||||
context: context,
|
||||
builder: (dialogContext) {
|
||||
final latestVersion = updateInfo['latestVersion'] as String;
|
||||
final releaseUrl = updateInfo['releaseUrl'] as String;
|
||||
|
||||
return AlertDialog(
|
||||
title: Text(title),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
t.update.versionAvailable(version: latestVersion),
|
||||
style: Theme.of(dialogContext).textTheme.titleMedium,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
t.update.currentVersion(version: updateInfo['currentVersion']),
|
||||
style: Theme.of(dialogContext).textTheme.bodySmall,
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
DialogActionButton(onPressed: () => Navigator.pop(dialogContext), label: dismissLabel),
|
||||
if (showSkipVersion)
|
||||
DialogActionButton(
|
||||
onPressed: () async {
|
||||
await UpdateService.skipVersion(latestVersion);
|
||||
if (dialogContext.mounted) Navigator.pop(dialogContext);
|
||||
},
|
||||
label: t.update.skipVersion,
|
||||
),
|
||||
DialogActionButton(
|
||||
onPressed: () async {
|
||||
final url = Uri.parse(releaseUrl);
|
||||
if (await canLaunchUrl(url)) {
|
||||
await launchUrl(url, mode: LaunchMode.externalApplication);
|
||||
}
|
||||
if (dialogContext.mounted) Navigator.pop(dialogContext);
|
||||
},
|
||||
label: t.update.viewRelease,
|
||||
isPrimary: true,
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -41,13 +41,11 @@ class ChapterSheet extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _ChapterSheetState extends State<ChapterSheet> {
|
||||
final _firstItemKey = GlobalKey();
|
||||
final _scrollController = ScrollController();
|
||||
bool _didInitialScroll = false;
|
||||
final _initialScroll = InitialItemScrollController();
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_scrollController.dispose();
|
||||
_initialScroll.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -82,13 +80,10 @@ class _ChapterSheetState extends State<ChapterSheet> {
|
||||
child: Text(t.videoControls.noChaptersAvailable, style: TextStyle(color: tokens(context).textMuted)),
|
||||
);
|
||||
} else {
|
||||
if (!_didInitialScroll && currentChapterIndex != null && currentChapterIndex > 0) {
|
||||
_didInitialScroll = true;
|
||||
scrollToCurrentItem(_scrollController, _firstItemKey, currentChapterIndex);
|
||||
}
|
||||
_initialScroll.maybeScrollTo(currentChapterIndex);
|
||||
|
||||
content = ListView.builder(
|
||||
controller: _scrollController,
|
||||
controller: _initialScroll.controller,
|
||||
itemCount: widget.chapters.length,
|
||||
itemBuilder: (context, index) {
|
||||
final chapter = widget.chapters[index];
|
||||
@@ -100,7 +95,7 @@ class _ChapterSheetState extends State<ChapterSheet> {
|
||||
: null;
|
||||
|
||||
return FocusableListTile(
|
||||
key: index == 0 ? _firstItemKey : null,
|
||||
key: index == 0 ? _initialScroll.firstItemKey : null,
|
||||
leading: chapter.thumb != null
|
||||
? SizedBox(
|
||||
width: 60,
|
||||
|
||||
@@ -28,13 +28,11 @@ class QueueSheet extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _QueueSheetState extends State<QueueSheet> {
|
||||
final _firstItemKey = GlobalKey();
|
||||
final _scrollController = ScrollController();
|
||||
bool _didInitialScroll = false;
|
||||
final _initialScroll = InitialItemScrollController();
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_scrollController.dispose();
|
||||
_initialScroll.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -52,13 +50,10 @@ class _QueueSheetState extends State<QueueSheet> {
|
||||
);
|
||||
} else {
|
||||
final currentIndex = items.indexWhere((item) => playbackState.playQueueItemIdFor(item) == currentItemID);
|
||||
if (!_didInitialScroll && currentIndex > 0) {
|
||||
_didInitialScroll = true;
|
||||
scrollToCurrentItem(_scrollController, _firstItemKey, currentIndex);
|
||||
}
|
||||
_initialScroll.maybeScrollTo(currentIndex);
|
||||
|
||||
content = ListView.builder(
|
||||
controller: _scrollController,
|
||||
controller: _initialScroll.controller,
|
||||
itemCount: items.length,
|
||||
itemBuilder: (context, index) {
|
||||
final item = items[index];
|
||||
@@ -66,7 +61,7 @@ class _QueueSheetState extends State<QueueSheet> {
|
||||
|
||||
final primaryColor = Theme.of(context).colorScheme.primary;
|
||||
return FocusableListTile(
|
||||
key: index == 0 ? _firstItemKey : null,
|
||||
key: index == 0 ? _initialScroll.firstItemKey : null,
|
||||
leading: _buildThumbnail(context, item, isCurrent),
|
||||
title: Text(
|
||||
item.title ?? '',
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class SheetColumnHeader extends StatelessWidget {
|
||||
final String label;
|
||||
|
||||
const SheetColumnHeader({super.key, required this.label});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
|
||||
child: Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Text(
|
||||
label,
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.titleSmall?.copyWith(color: Theme.of(context).colorScheme.onSurfaceVariant),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import '../../../widgets/app_icon.dart';
|
||||
import '../../../widgets/focusable_list_tile.dart';
|
||||
import '../../../widgets/overlay_sheet.dart';
|
||||
import 'base_video_control_sheet.dart';
|
||||
import 'sheet_column_header.dart';
|
||||
import 'subtitle_search_sheet.dart';
|
||||
import '../helpers/track_filter_helper.dart';
|
||||
import '../helpers/track_selection_helper.dart';
|
||||
@@ -188,40 +189,33 @@ class _SourceAudioColumn extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _SourceAudioColumnState extends State<_SourceAudioColumn> {
|
||||
final _firstItemKey = GlobalKey();
|
||||
final _scrollController = ScrollController();
|
||||
bool _didInitialScroll = false;
|
||||
final _initialScroll = InitialItemScrollController();
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_scrollController.dispose();
|
||||
_initialScroll.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final selectedId = widget.selectedStreamId;
|
||||
if (!_didInitialScroll && selectedId != null) {
|
||||
final selectedIndex = widget.tracks.indexWhere((t) => t.id == selectedId);
|
||||
if (selectedIndex > 0) {
|
||||
_didInitialScroll = true;
|
||||
scrollToCurrentItem(_scrollController, _firstItemKey, selectedIndex);
|
||||
}
|
||||
}
|
||||
final selectedIndex = selectedId == null ? null : widget.tracks.indexWhere((t) => t.id == selectedId);
|
||||
_initialScroll.maybeScrollTo(selectedIndex);
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
if (widget.showHeader) _ColumnHeader(label: t.videoControls.audioLabel),
|
||||
if (widget.showHeader) SheetColumnHeader(label: t.videoControls.audioLabel),
|
||||
Expanded(
|
||||
child: ListView.builder(
|
||||
controller: _scrollController,
|
||||
controller: _initialScroll.controller,
|
||||
itemCount: widget.tracks.length,
|
||||
itemBuilder: (context, index) {
|
||||
final track = widget.tracks[index];
|
||||
final isSelected = track.id == selectedId;
|
||||
return TrackSelectionHelper.buildTrackTile<AudioTrack>(
|
||||
context: context,
|
||||
key: index == 0 ? _firstItemKey : null,
|
||||
key: index == 0 ? _initialScroll.firstItemKey : null,
|
||||
label: track.label,
|
||||
isSelected: isSelected,
|
||||
onTap: () {
|
||||
@@ -257,34 +251,26 @@ class _AudioColumn extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _AudioColumnState extends State<_AudioColumn> {
|
||||
final _firstItemKey = GlobalKey();
|
||||
final _scrollController = ScrollController();
|
||||
bool _didInitialScroll = false;
|
||||
final _initialScroll = InitialItemScrollController();
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_scrollController.dispose();
|
||||
_initialScroll.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final selectedId = widget.selection.audio?.id ?? '';
|
||||
|
||||
if (!_didInitialScroll) {
|
||||
final selectedIndex = widget.tracks.indexWhere((t) => t.id == selectedId);
|
||||
if (selectedIndex > 0) {
|
||||
_didInitialScroll = true;
|
||||
scrollToCurrentItem(_scrollController, _firstItemKey, selectedIndex);
|
||||
}
|
||||
}
|
||||
final selectedIndex = widget.tracks.indexWhere((t) => t.id == selectedId);
|
||||
_initialScroll.maybeScrollTo(selectedIndex);
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
if (widget.showHeader) _ColumnHeader(label: t.videoControls.audioLabel),
|
||||
if (widget.showHeader) SheetColumnHeader(label: t.videoControls.audioLabel),
|
||||
Expanded(
|
||||
child: ListView.builder(
|
||||
controller: _scrollController,
|
||||
controller: _initialScroll.controller,
|
||||
itemCount: widget.tracks.length,
|
||||
itemBuilder: (context, index) {
|
||||
final track = widget.tracks[index];
|
||||
@@ -297,7 +283,7 @@ class _AudioColumnState extends State<_AudioColumn> {
|
||||
);
|
||||
return TrackSelectionHelper.buildTrackTile<AudioTrack>(
|
||||
context: context,
|
||||
key: index == 0 ? _firstItemKey : null,
|
||||
key: index == 0 ? _initialScroll.firstItemKey : null,
|
||||
label: label,
|
||||
isSelected: track.id == selectedId,
|
||||
onTap: () {
|
||||
@@ -348,13 +334,11 @@ class _SubtitleColumn extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _SubtitleColumnState extends State<_SubtitleColumn> {
|
||||
final _firstItemKey = GlobalKey();
|
||||
final _scrollController = ScrollController();
|
||||
bool _didInitialScroll = false;
|
||||
final _initialScroll = InitialItemScrollController();
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_scrollController.dispose();
|
||||
_initialScroll.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -368,28 +352,22 @@ class _SubtitleColumnState extends State<_SubtitleColumn> {
|
||||
// +1 for "Off" row
|
||||
final itemCount = widget.tracks.length + 1;
|
||||
|
||||
if (!_didInitialScroll && !isOffSelected) {
|
||||
// +1 because index 0 is the "Off" row
|
||||
final selectedIndex = widget.tracks.indexWhere((t) => t.id == selectedSub.id) + 1;
|
||||
if (selectedIndex > 0) {
|
||||
_didInitialScroll = true;
|
||||
scrollToCurrentItem(_scrollController, _firstItemKey, selectedIndex);
|
||||
}
|
||||
}
|
||||
final selectedIndex = isOffSelected ? null : widget.tracks.indexWhere((t) => t.id == selectedSub.id) + 1;
|
||||
_initialScroll.maybeScrollTo(selectedIndex);
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
if (widget.showHeader) _ColumnHeader(label: t.videoControls.subtitlesLabel),
|
||||
if (widget.showHeader) SheetColumnHeader(label: t.videoControls.subtitlesLabel),
|
||||
Expanded(
|
||||
child: ListView.builder(
|
||||
controller: _scrollController,
|
||||
controller: _initialScroll.controller,
|
||||
itemCount: itemCount,
|
||||
itemBuilder: (context, index) {
|
||||
// "Off" row
|
||||
if (index == 0) {
|
||||
return TrackSelectionHelper.buildOffTile<SubtitleTrack>(
|
||||
context: context,
|
||||
key: _firstItemKey,
|
||||
key: _initialScroll.firstItemKey,
|
||||
isSelected: isOffSelected,
|
||||
onTap: () {
|
||||
// Turning off primary also clears secondary
|
||||
@@ -500,25 +478,3 @@ class _SubtitleColumnState extends State<_SubtitleColumn> {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ColumnHeader extends StatelessWidget {
|
||||
final String label;
|
||||
|
||||
const _ColumnHeader({required this.label});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
|
||||
child: Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Text(
|
||||
label,
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.titleSmall?.copyWith(color: Theme.of(context).colorScheme.onSurfaceVariant),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import '../../../utils/scroll_utils.dart';
|
||||
import '../../../widgets/focusable_list_tile.dart';
|
||||
import '../../../widgets/overlay_sheet.dart';
|
||||
import 'base_video_control_sheet.dart';
|
||||
import 'sheet_column_header.dart';
|
||||
|
||||
/// Combined sheet for selecting the media [version] (left) and transcode
|
||||
/// [quality] preset (right). The version column is hidden when there is only
|
||||
@@ -137,35 +138,30 @@ class _VersionColumn extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _VersionColumnState extends State<_VersionColumn> {
|
||||
final _firstItemKey = GlobalKey();
|
||||
final _scrollController = ScrollController();
|
||||
bool _didInitialScroll = false;
|
||||
final _initialScroll = InitialItemScrollController();
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_scrollController.dispose();
|
||||
_initialScroll.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (!_didInitialScroll && widget.selectedIndex > 0) {
|
||||
_didInitialScroll = true;
|
||||
scrollToCurrentItem(_scrollController, _firstItemKey, widget.selectedIndex);
|
||||
}
|
||||
_initialScroll.maybeScrollTo(widget.selectedIndex);
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
if (widget.showHeader) _ColumnHeader(label: t.videoControls.versionColumnHeader),
|
||||
if (widget.showHeader) SheetColumnHeader(label: t.videoControls.versionColumnHeader),
|
||||
Expanded(
|
||||
child: ListView.builder(
|
||||
controller: _scrollController,
|
||||
controller: _initialScroll.controller,
|
||||
itemCount: widget.versions.length,
|
||||
itemBuilder: (context, index) {
|
||||
final version = widget.versions[index];
|
||||
final isSelected = index == widget.selectedIndex;
|
||||
return _SelectionTile(
|
||||
key: index == 0 ? _firstItemKey : null,
|
||||
key: index == 0 ? _initialScroll.firstItemKey : null,
|
||||
label: version.displayLabel,
|
||||
isSelected: isSelected,
|
||||
onTap: () => widget.onSelected(index),
|
||||
@@ -202,13 +198,11 @@ class _QualityColumn extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _QualityColumnState extends State<_QualityColumn> {
|
||||
final _firstItemKey = GlobalKey();
|
||||
final _scrollController = ScrollController();
|
||||
bool _didInitialScroll = false;
|
||||
final _initialScroll = InitialItemScrollController();
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_scrollController.dispose();
|
||||
_initialScroll.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -217,17 +211,14 @@ class _QualityColumnState extends State<_QualityColumn> {
|
||||
final presets = TranscodeQualityPreset.displayOrder;
|
||||
final selectedIndex = presets.indexOf(widget.selected);
|
||||
|
||||
if (!_didInitialScroll && selectedIndex > 0) {
|
||||
_didInitialScroll = true;
|
||||
scrollToCurrentItem(_scrollController, _firstItemKey, selectedIndex);
|
||||
}
|
||||
_initialScroll.maybeScrollTo(selectedIndex);
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
if (widget.showHeader) _ColumnHeader(label: t.videoControls.qualityColumnHeader),
|
||||
if (widget.showHeader) SheetColumnHeader(label: t.videoControls.qualityColumnHeader),
|
||||
Expanded(
|
||||
child: ListView.builder(
|
||||
controller: _scrollController,
|
||||
controller: _initialScroll.controller,
|
||||
itemCount: presets.length,
|
||||
itemBuilder: (context, index) {
|
||||
final preset = presets[index];
|
||||
@@ -243,7 +234,7 @@ class _QualityColumnState extends State<_QualityColumn> {
|
||||
);
|
||||
|
||||
return _SelectionTile(
|
||||
key: index == 0 ? _firstItemKey : null,
|
||||
key: index == 0 ? _initialScroll.firstItemKey : null,
|
||||
label: qualityPresetLabel(preset),
|
||||
trailingText: trailing,
|
||||
isSelected: isSelected,
|
||||
@@ -302,25 +293,3 @@ class _SelectionTile extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ColumnHeader extends StatelessWidget {
|
||||
final String label;
|
||||
|
||||
const _ColumnHeader({required this.label});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
|
||||
child: Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Text(
|
||||
label,
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.titleSmall?.copyWith(color: Theme.of(context).colorScheme.onSurfaceVariant),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:plezy/services/trackers/tracker_session_utils.dart';
|
||||
|
||||
void main() {
|
||||
group('tracker token expiry helpers', () {
|
||||
test('detects expired token', () {
|
||||
expect(isTrackerTokenExpired(100, nowSeconds: 100), isTrue);
|
||||
expect(isTrackerTokenExpired(101, nowSeconds: 100), isFalse);
|
||||
});
|
||||
|
||||
test('detects refresh window', () {
|
||||
expect(trackerTokenNeedsRefresh(400, nowSeconds: 100), isTrue);
|
||||
expect(trackerTokenNeedsRefresh(401, nowSeconds: 100), isFalse);
|
||||
expect(trackerTokenNeedsRefresh(110, refreshWindowSeconds: 10, nowSeconds: 100), isTrue);
|
||||
});
|
||||
});
|
||||
|
||||
group('tracker session json codec', () {
|
||||
test('round-trips through provided factory', () {
|
||||
final encoded = encodeTrackerSessionJson({'access_token': 'abc', 'created_at': 123});
|
||||
final decoded = decodeTrackerSessionJson(encoded, (json) => json);
|
||||
|
||||
expect(decoded, {'access_token': 'abc', 'created_at': 123});
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -60,6 +60,31 @@ void main() {
|
||||
});
|
||||
});
|
||||
|
||||
group('flexibleBoolNullable', () {
|
||||
test('returns bool as-is', () {
|
||||
expect(flexibleBoolNullable(true), isTrue);
|
||||
expect(flexibleBoolNullable(false), isFalse);
|
||||
});
|
||||
|
||||
test('maps 1 to true, other ints to false', () {
|
||||
expect(flexibleBoolNullable(1), isTrue);
|
||||
expect(flexibleBoolNullable(0), isFalse);
|
||||
expect(flexibleBoolNullable(2), isFalse);
|
||||
});
|
||||
|
||||
test("maps '1' string to true, other strings to false", () {
|
||||
expect(flexibleBoolNullable('1'), isTrue);
|
||||
expect(flexibleBoolNullable('0'), isFalse);
|
||||
expect(flexibleBoolNullable('true'), isFalse);
|
||||
});
|
||||
|
||||
test('returns null for null and unsupported types', () {
|
||||
expect(flexibleBoolNullable(null), isNull);
|
||||
expect(flexibleBoolNullable(1.0), isNull);
|
||||
expect(flexibleBoolNullable(<String, Object>{}), isNull);
|
||||
});
|
||||
});
|
||||
|
||||
group('flexibleDouble', () {
|
||||
test('parses num as double', () {
|
||||
expect(flexibleDouble(1.5), 1.5);
|
||||
|
||||
Reference in New Issue
Block a user