refactor: share low-risk UI and tracker helpers

This commit is contained in:
edde746
2026-05-02 01:58:50 +02:00
parent 646832c6b1
commit 50bc245677
18 changed files with 270 additions and 265 deletions
+9 -49
View File
@@ -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() {
+5 -38
View File
@@ -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),
);
}
}
+11 -21
View File
@@ -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);
}
+7 -8
View File
@@ -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>);
}
+6 -6
View File
@@ -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);
}
+9
View File
@@ -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(),
+15
View File
@@ -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,
+63
View File
@@ -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});
});
});
}
+25
View File
@@ -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);