Merge branch 'companion-remote'
# Conflicts: # lib/i18n/strings.g.dart
This commit is contained in:
@@ -3,6 +3,9 @@
|
||||
<!-- Internet access permissions -->
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
|
||||
<!-- Camera permission for QR code scanning (companion remote pairing) -->
|
||||
<uses-permission android:name="android.permission.CAMERA"/>
|
||||
|
||||
<!-- PIP Permissions -->
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK"/>
|
||||
|
||||
@@ -21,6 +21,6 @@
|
||||
<key>CFBundleVersion</key>
|
||||
<string>1.0</string>
|
||||
<key>MinimumOSVersion</key>
|
||||
<string>13.0</string>
|
||||
<string>15.5</string>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
# Uncomment this line to define a global platform for your project
|
||||
platform :ios, '14.0'
|
||||
platform :ios, '15.5'
|
||||
|
||||
# CocoaPods analytics sends network stats synchronously affecting flutter build latency.
|
||||
ENV['COCOAPODS_DISABLE_STATS'] = 'true'
|
||||
|
||||
+76
-1
@@ -6,8 +6,56 @@ PODS:
|
||||
- file_picker (0.0.1):
|
||||
- Flutter
|
||||
- Flutter (1.0.0)
|
||||
- GoogleDataTransport (10.1.0):
|
||||
- nanopb (~> 3.30910.0)
|
||||
- PromisesObjC (~> 2.4)
|
||||
- GoogleMLKit/BarcodeScanning (7.0.0):
|
||||
- GoogleMLKit/MLKitCore
|
||||
- MLKitBarcodeScanning (~> 6.0.0)
|
||||
- GoogleMLKit/MLKitCore (7.0.0):
|
||||
- MLKitCommon (~> 12.0.0)
|
||||
- GoogleToolboxForMac/Defines (4.2.1)
|
||||
- GoogleToolboxForMac/Logger (4.2.1):
|
||||
- GoogleToolboxForMac/Defines (= 4.2.1)
|
||||
- "GoogleToolboxForMac/NSData+zlib (4.2.1)":
|
||||
- GoogleToolboxForMac/Defines (= 4.2.1)
|
||||
- GoogleUtilities/Environment (8.1.0):
|
||||
- GoogleUtilities/Privacy
|
||||
- GoogleUtilities/Logger (8.1.0):
|
||||
- GoogleUtilities/Environment
|
||||
- GoogleUtilities/Privacy
|
||||
- GoogleUtilities/Privacy (8.1.0)
|
||||
- GoogleUtilities/UserDefaults (8.1.0):
|
||||
- GoogleUtilities/Logger
|
||||
- GoogleUtilities/Privacy
|
||||
- GTMSessionFetcher/Core (3.5.0)
|
||||
- in_app_review (2.0.0):
|
||||
- Flutter
|
||||
- MLImage (1.0.0-beta6)
|
||||
- MLKitBarcodeScanning (6.0.0):
|
||||
- MLKitCommon (~> 12.0)
|
||||
- MLKitVision (~> 8.0)
|
||||
- MLKitCommon (12.0.0):
|
||||
- GoogleDataTransport (~> 10.0)
|
||||
- GoogleToolboxForMac/Logger (< 5.0, >= 4.2.1)
|
||||
- "GoogleToolboxForMac/NSData+zlib (< 5.0, >= 4.2.1)"
|
||||
- GoogleUtilities/Logger (~> 8.0)
|
||||
- GoogleUtilities/UserDefaults (~> 8.0)
|
||||
- GTMSessionFetcher/Core (< 4.0, >= 3.3.2)
|
||||
- MLKitVision (8.0.0):
|
||||
- GoogleToolboxForMac/Logger (< 5.0, >= 4.2.1)
|
||||
- "GoogleToolboxForMac/NSData+zlib (< 5.0, >= 4.2.1)"
|
||||
- GTMSessionFetcher/Core (< 4.0, >= 3.3.2)
|
||||
- MLImage (= 1.0.0-beta6)
|
||||
- MLKitCommon (~> 12.0)
|
||||
- mobile_scanner (6.0.2):
|
||||
- Flutter
|
||||
- GoogleMLKit/BarcodeScanning (~> 7.0.0)
|
||||
- nanopb (3.30910.0):
|
||||
- nanopb/decode (= 3.30910.0)
|
||||
- nanopb/encode (= 3.30910.0)
|
||||
- nanopb/decode (3.30910.0)
|
||||
- nanopb/encode (3.30910.0)
|
||||
- os_media_controls (0.0.1):
|
||||
- Flutter
|
||||
- package_info_plus (0.4.5):
|
||||
@@ -15,6 +63,7 @@ PODS:
|
||||
- path_provider_foundation (0.0.1):
|
||||
- Flutter
|
||||
- FlutterMacOS
|
||||
- PromisesObjC (2.4.0)
|
||||
- shared_preferences_foundation (0.0.1):
|
||||
- Flutter
|
||||
- FlutterMacOS
|
||||
@@ -61,6 +110,7 @@ DEPENDENCIES:
|
||||
- file_picker (from `.symlinks/plugins/file_picker/ios`)
|
||||
- Flutter (from `Flutter`)
|
||||
- in_app_review (from `.symlinks/plugins/in_app_review/ios`)
|
||||
- mobile_scanner (from `.symlinks/plugins/mobile_scanner/ios`)
|
||||
- os_media_controls (from `.symlinks/plugins/os_media_controls/ios`)
|
||||
- package_info_plus (from `.symlinks/plugins/package_info_plus/ios`)
|
||||
- path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`)
|
||||
@@ -74,6 +124,17 @@ DEPENDENCIES:
|
||||
|
||||
SPEC REPOS:
|
||||
trunk:
|
||||
- GoogleDataTransport
|
||||
- GoogleMLKit
|
||||
- GoogleToolboxForMac
|
||||
- GoogleUtilities
|
||||
- GTMSessionFetcher
|
||||
- MLImage
|
||||
- MLKitBarcodeScanning
|
||||
- MLKitCommon
|
||||
- MLKitVision
|
||||
- nanopb
|
||||
- PromisesObjC
|
||||
- sqlite3
|
||||
|
||||
EXTERNAL SOURCES:
|
||||
@@ -87,6 +148,8 @@ EXTERNAL SOURCES:
|
||||
:path: Flutter
|
||||
in_app_review:
|
||||
:path: ".symlinks/plugins/in_app_review/ios"
|
||||
mobile_scanner:
|
||||
:path: ".symlinks/plugins/mobile_scanner/ios"
|
||||
os_media_controls:
|
||||
:path: ".symlinks/plugins/os_media_controls/ios"
|
||||
package_info_plus:
|
||||
@@ -113,10 +176,22 @@ SPEC CHECKSUMS:
|
||||
device_info_plus: 21fcca2080fbcd348be798aa36c3e5ed849eefbe
|
||||
file_picker: 8fc6fe5e42585a217d44d22f79ec046cb8d81140
|
||||
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
|
||||
GoogleDataTransport: aae35b7ea0c09004c3797d53c8c41f66f219d6a7
|
||||
GoogleMLKit: eff9e23ec1d90ea4157a1ee2e32a4f610c5b3318
|
||||
GoogleToolboxForMac: d1a2cbf009c453f4d6ded37c105e2f67a32206d8
|
||||
GoogleUtilities: 00c88b9a86066ef77f0da2fab05f65d7768ed8e1
|
||||
GTMSessionFetcher: 5aea5ba6bd522a239e236100971f10cb71b96ab6
|
||||
in_app_review: 7dd1ea365263f834b8464673f9df72c80c17c937
|
||||
MLImage: 0ad1c5f50edd027672d8b26b0fee78a8b4a0fc56
|
||||
MLKitBarcodeScanning: 0a3064da0a7f49ac24ceb3cb46a5bc67496facd2
|
||||
MLKitCommon: 07c2c33ae5640e5380beaaa6e4b9c249a205542d
|
||||
MLKitVision: 45e79d68845a2de77e2dd4d7f07947f0ed157b0e
|
||||
mobile_scanner: af8f71879eaba2bbcb4d86c6a462c3c0e7f23036
|
||||
nanopb: fad817b59e0457d11a5dfbde799381cd727c1275
|
||||
os_media_controls: 86dceab6245a5325af90fc0fdebe243c42d789b4
|
||||
package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499
|
||||
path_provider_foundation: bb55f6dbba17d0dccd6737fe6f7f34fbd0376880
|
||||
PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47
|
||||
shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb
|
||||
sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0
|
||||
sqlite3: 8d708bc63e9f4ce48f0ad9d6269e478c5ced1d9b
|
||||
@@ -126,6 +201,6 @@ SPEC CHECKSUMS:
|
||||
wakelock_plus: e29112ab3ef0b318e58cfa5c32326458be66b556
|
||||
workmanager_apple: 904529ae31e97fc5be632cf628507652294a0778
|
||||
|
||||
PODFILE CHECKSUM: faeab82d8b6e3c9d039ddf62b7666988a3dea540
|
||||
PODFILE CHECKSUM: 16bb7f67e16d8aaa2bfb97666803fbfb291b6559
|
||||
|
||||
COCOAPODS: 1.16.2
|
||||
|
||||
@@ -214,6 +214,7 @@
|
||||
9705A1C41CF9048500538489 /* Embed Frameworks */,
|
||||
3B06AD1E1E4923F5004D2608 /* Thin Binary */,
|
||||
2E10BAE564A4AFFCFF5B4EF0 /* [CP] Embed Pods Frameworks */,
|
||||
BE3E47DA555D39F032C6A1DB /* [CP] Copy Pods Resources */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
@@ -380,6 +381,23 @@
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build";
|
||||
};
|
||||
BE3E47DA555D39F032C6A1DB /* [CP] Copy Pods Resources */ = {
|
||||
isa = PBXShellScriptBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
inputFileListPaths = (
|
||||
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-input-files.xcfilelist",
|
||||
);
|
||||
name = "[CP] Copy Pods Resources";
|
||||
outputFileListPaths = (
|
||||
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-output-files.xcfilelist",
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n";
|
||||
showEnvVarsInLog = 0;
|
||||
};
|
||||
/* End PBXShellScriptBuildPhase section */
|
||||
|
||||
/* Begin PBXSourcesBuildPhase section */
|
||||
@@ -475,7 +493,7 @@
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.5;
|
||||
MTL_ENABLE_DEBUG_INFO = NO;
|
||||
SDKROOT = iphoneos;
|
||||
SUPPORTED_PLATFORMS = iphoneos;
|
||||
@@ -496,7 +514,7 @@
|
||||
INFOPLIST_FILE = Runner/Info.plist;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = Plezy;
|
||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.entertainment";
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.5;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
@@ -609,7 +627,7 @@
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.5;
|
||||
MTL_ENABLE_DEBUG_INFO = YES;
|
||||
ONLY_ACTIVE_ARCH = YES;
|
||||
SDKROOT = iphoneos;
|
||||
@@ -660,7 +678,7 @@
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.5;
|
||||
MTL_ENABLE_DEBUG_INFO = NO;
|
||||
SDKROOT = iphoneos;
|
||||
SUPPORTED_PLATFORMS = iphoneos;
|
||||
@@ -683,7 +701,7 @@
|
||||
INFOPLIST_FILE = Runner/Info.plist;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = Plezy;
|
||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.entertainment";
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.5;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
@@ -710,7 +728,7 @@
|
||||
INFOPLIST_FILE = Runner/Info.plist;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = Plezy;
|
||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.entertainment";
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.5;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
|
||||
@@ -40,7 +40,7 @@
|
||||
<key>NSLocalNetworkUsageDescription</key>
|
||||
<string>This app needs to connect to your Plex Media Server on your local network.</string>
|
||||
<key>NSCameraUsageDescription</key>
|
||||
<string>Camera access is included by the WebRTC library used for Watch Together, but is not actively used by this app.</string>
|
||||
<string>Camera is used to scan QR codes for Companion Remote pairing.</string>
|
||||
<key>UIApplicationSupportsIndirectInputEvents</key>
|
||||
<true/>
|
||||
<key>UIFileSharingEnabled</key>
|
||||
|
||||
@@ -4,6 +4,7 @@ import 'package:flutter/services.dart';
|
||||
import '../utils/platform_detector.dart';
|
||||
import '../services/gamepad_service.dart';
|
||||
import 'dpad_navigator.dart';
|
||||
import '../services/companion_remote/companion_remote_receiver.dart';
|
||||
|
||||
/// Tracks whether the user is navigating via keyboard/d-pad or pointer (mouse/touch).
|
||||
///
|
||||
@@ -57,12 +58,16 @@ class _InputModeTrackerState extends State<InputModeTracker> {
|
||||
|
||||
// Register callback for gamepad input to switch to keyboard mode
|
||||
GamepadService.onGamepadInput = () => _setMode(InputMode.keyboard);
|
||||
|
||||
// Register callback for companion remote input to switch to keyboard mode
|
||||
CompanionRemoteReceiver.onRemoteInput = () => _setMode(InputMode.keyboard);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
HardwareKeyboard.instance.removeHandler(_handleKeyEvent);
|
||||
GamepadService.onGamepadInput = null;
|
||||
CompanionRemoteReceiver.onRemoteInput = null;
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -112,7 +117,15 @@ class _InputModeTrackerState extends State<InputModeTracker> {
|
||||
onPointerDown: (_) => _setMode(InputMode.pointer),
|
||||
onPointerHover: (_) => _setMode(InputMode.pointer),
|
||||
behavior: HitTestBehavior.translucent,
|
||||
child: _InputModeProvider(mode: _mode, child: widget.child),
|
||||
child: MouseRegion(
|
||||
cursor: _mode == InputMode.keyboard
|
||||
? SystemMouseCursors.none
|
||||
: MouseCursor.defer,
|
||||
child: IgnorePointer(
|
||||
ignoring: _mode == InputMode.keyboard,
|
||||
child: _InputModeProvider(mode: _mode, child: widget.child),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,9 +4,9 @@
|
||||
/// To regenerate, run: `dart run slang`
|
||||
///
|
||||
/// Locales: 9
|
||||
/// Strings: 5130 (570 per locale)
|
||||
/// Strings: 5121 (569 per locale)
|
||||
///
|
||||
/// Built on 2026-02-09 at 17:54 UTC
|
||||
/// Built on 2026-02-09 at 18:36 UTC
|
||||
|
||||
// coverage:ignore-file
|
||||
// ignore_for_file: type=lint, unused_import
|
||||
|
||||
@@ -25,6 +25,7 @@ import 'providers/download_provider.dart';
|
||||
import 'providers/offline_mode_provider.dart';
|
||||
import 'providers/offline_watch_provider.dart';
|
||||
import 'providers/shader_provider.dart';
|
||||
import 'providers/companion_remote_provider.dart';
|
||||
import 'watch_together/watch_together.dart';
|
||||
import 'services/multi_server_manager.dart';
|
||||
import 'services/offline_watch_sync_service.dart';
|
||||
@@ -307,6 +308,7 @@ class _MainAppState extends State<MainApp> with WidgetsBindingObserver {
|
||||
ChangeNotifierProvider(create: (context) => PlaybackStateProvider()),
|
||||
ChangeNotifierProvider(create: (context) => WatchTogetherProvider()),
|
||||
ChangeNotifierProvider(create: (context) => ShaderProvider()),
|
||||
ChangeNotifierProvider(create: (context) => CompanionRemoteProvider()),
|
||||
],
|
||||
child: Consumer<ThemeProvider>(
|
||||
builder: (context, themeProvider, child) {
|
||||
|
||||
@@ -15,6 +15,7 @@ mixin FocusableTab {
|
||||
/// Mixin for screens with focusable search input
|
||||
mixin SearchInputFocusable {
|
||||
void focusSearchInput();
|
||||
void setSearchQuery(String query);
|
||||
}
|
||||
|
||||
/// Mixin for screens that can load a specific library by key
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import 'package:json_annotation/json_annotation.dart';
|
||||
|
||||
part 'recent_remote_session.g.dart';
|
||||
|
||||
/// Recent Companion Remote session for quick reconnection
|
||||
@JsonSerializable()
|
||||
class RecentRemoteSession {
|
||||
final String sessionId;
|
||||
final String pin;
|
||||
final String deviceName;
|
||||
final String platform;
|
||||
final DateTime lastConnected;
|
||||
final String? hostAddress; // Format: "ip:port"
|
||||
|
||||
RecentRemoteSession({
|
||||
required this.sessionId,
|
||||
required this.pin,
|
||||
required this.deviceName,
|
||||
required this.platform,
|
||||
required this.lastConnected,
|
||||
this.hostAddress,
|
||||
});
|
||||
|
||||
factory RecentRemoteSession.fromJson(Map<String, dynamic> json) => _$RecentRemoteSessionFromJson(json);
|
||||
|
||||
Map<String, dynamic> toJson() => _$RecentRemoteSessionToJson(this);
|
||||
|
||||
/// Create from QR code data (format: "ip|port|sessionId|pin")
|
||||
factory RecentRemoteSession.fromQrData(String qrData) {
|
||||
final parts = qrData.split('|');
|
||||
if (parts.length < 4) {
|
||||
throw FormatException('Invalid QR code format - expected ip|port|sessionId|pin');
|
||||
}
|
||||
|
||||
final ip = parts[0];
|
||||
final port = parts[1];
|
||||
final sessionId = parts[2];
|
||||
final pin = parts[3];
|
||||
|
||||
return RecentRemoteSession(
|
||||
sessionId: sessionId,
|
||||
pin: pin,
|
||||
deviceName: 'Unknown Device',
|
||||
platform: 'unknown',
|
||||
lastConnected: DateTime.now(),
|
||||
hostAddress: '$ip:$port',
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() => '$deviceName ($platform) - Last: ${lastConnected.toLocal()}';
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'recent_remote_session.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// JsonSerializableGenerator
|
||||
// **************************************************************************
|
||||
|
||||
RecentRemoteSession _$RecentRemoteSessionFromJson(Map<String, dynamic> json) => RecentRemoteSession(
|
||||
sessionId: json['sessionId'] as String,
|
||||
pin: json['pin'] as String,
|
||||
deviceName: json['deviceName'] as String,
|
||||
platform: json['platform'] as String,
|
||||
lastConnected: DateTime.parse(json['lastConnected'] as String),
|
||||
hostAddress: json['hostAddress'] as String?,
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$RecentRemoteSessionToJson(RecentRemoteSession instance) => <String, dynamic>{
|
||||
'sessionId': instance.sessionId,
|
||||
'pin': instance.pin,
|
||||
'deviceName': instance.deviceName,
|
||||
'platform': instance.platform,
|
||||
'lastConnected': instance.lastConnected.toIso8601String(),
|
||||
'hostAddress': instance.hostAddress,
|
||||
};
|
||||
@@ -0,0 +1,26 @@
|
||||
import 'remote_command_type.dart';
|
||||
|
||||
class RemoteCommand {
|
||||
final RemoteCommandType type;
|
||||
final Map<String, dynamic>? data;
|
||||
|
||||
const RemoteCommand({required this.type, this.data});
|
||||
|
||||
factory RemoteCommand.fromJson(Map<String, dynamic> json) {
|
||||
final index = json['t'] as int;
|
||||
return RemoteCommand(
|
||||
type: index < RemoteCommandType.values.length ? RemoteCommandType.values[index] : RemoteCommandType.ping,
|
||||
data: json['d'] as Map<String, dynamic>?,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
't': type.index,
|
||||
if (data != null) 'd': data,
|
||||
};
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() => 'RemoteCommand(${type.name}, data: $data)';
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
enum RemoteCommandType {
|
||||
// Navigation
|
||||
dpadUp,
|
||||
dpadDown,
|
||||
dpadLeft,
|
||||
dpadRight,
|
||||
select,
|
||||
back,
|
||||
contextMenu,
|
||||
|
||||
// Playback
|
||||
play,
|
||||
pause,
|
||||
playPause,
|
||||
stop,
|
||||
seekForward,
|
||||
seekBackward,
|
||||
nextTrack,
|
||||
previousTrack,
|
||||
skipIntro,
|
||||
skipCredits,
|
||||
|
||||
// Volume
|
||||
volumeUp,
|
||||
volumeDown,
|
||||
volumeMute,
|
||||
volumeSet,
|
||||
|
||||
// Tab Navigation
|
||||
tabNext,
|
||||
tabPrevious,
|
||||
tabDiscover,
|
||||
tabLibraries,
|
||||
tabSearch,
|
||||
tabDownloads,
|
||||
tabSettings,
|
||||
|
||||
// Quick Actions
|
||||
home,
|
||||
search,
|
||||
subtitles,
|
||||
audioTracks,
|
||||
qualitySettings,
|
||||
fullscreen,
|
||||
|
||||
// Session Management
|
||||
ping,
|
||||
pong,
|
||||
deviceInfo,
|
||||
disconnect,
|
||||
ack,
|
||||
syncState,
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import 'package:json_annotation/json_annotation.dart';
|
||||
|
||||
part 'remote_session.g.dart';
|
||||
|
||||
enum RemoteSessionRole { host, remote }
|
||||
|
||||
enum RemoteSessionStatus { disconnected, connecting, connected, reconnecting, error }
|
||||
|
||||
@JsonSerializable()
|
||||
class RemoteDevice {
|
||||
final String id;
|
||||
final String name;
|
||||
final String platform;
|
||||
final DateTime connectedAt;
|
||||
final Map<String, bool> capabilities;
|
||||
|
||||
RemoteDevice({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.platform,
|
||||
DateTime? connectedAt,
|
||||
Map<String, bool>? capabilities,
|
||||
}) : connectedAt = connectedAt ?? DateTime.now(),
|
||||
capabilities = capabilities ?? {};
|
||||
|
||||
factory RemoteDevice.fromJson(Map<String, dynamic> json) => _$RemoteDeviceFromJson(json);
|
||||
|
||||
Map<String, dynamic> toJson() => _$RemoteDeviceToJson(this);
|
||||
|
||||
RemoteDevice copyWith({
|
||||
String? id,
|
||||
String? name,
|
||||
String? platform,
|
||||
DateTime? connectedAt,
|
||||
Map<String, bool>? capabilities,
|
||||
}) {
|
||||
return RemoteDevice(
|
||||
id: id ?? this.id,
|
||||
name: name ?? this.name,
|
||||
platform: platform ?? this.platform,
|
||||
connectedAt: connectedAt ?? this.connectedAt,
|
||||
capabilities: capabilities ?? this.capabilities,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
if (identical(this, other)) return true;
|
||||
|
||||
return other is RemoteDevice && other.id == id;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => id.hashCode;
|
||||
}
|
||||
|
||||
@JsonSerializable()
|
||||
class RemoteSession {
|
||||
final String sessionId;
|
||||
final String pin;
|
||||
@JsonKey(unknownEnumValue: RemoteSessionRole.remote)
|
||||
final RemoteSessionRole role;
|
||||
@JsonKey(unknownEnumValue: RemoteSessionStatus.disconnected)
|
||||
final RemoteSessionStatus status;
|
||||
final RemoteDevice? connectedDevice;
|
||||
final DateTime createdAt;
|
||||
final String? errorMessage;
|
||||
|
||||
RemoteSession({
|
||||
required this.sessionId,
|
||||
required this.pin,
|
||||
required this.role,
|
||||
this.status = RemoteSessionStatus.disconnected,
|
||||
this.connectedDevice,
|
||||
DateTime? createdAt,
|
||||
this.errorMessage,
|
||||
}) : createdAt = createdAt ?? DateTime.now();
|
||||
|
||||
bool get isConnected => status == RemoteSessionStatus.connected;
|
||||
bool get isHost => role == RemoteSessionRole.host;
|
||||
bool get isRemote => role == RemoteSessionRole.remote;
|
||||
|
||||
factory RemoteSession.fromJson(Map<String, dynamic> json) => _$RemoteSessionFromJson(json);
|
||||
|
||||
Map<String, dynamic> toJson() => _$RemoteSessionToJson(this);
|
||||
|
||||
RemoteSession copyWith({
|
||||
String? sessionId,
|
||||
String? pin,
|
||||
RemoteSessionRole? role,
|
||||
RemoteSessionStatus? status,
|
||||
RemoteDevice? connectedDevice,
|
||||
bool clearConnectedDevice = false,
|
||||
DateTime? createdAt,
|
||||
String? errorMessage,
|
||||
bool clearErrorMessage = false,
|
||||
}) {
|
||||
return RemoteSession(
|
||||
sessionId: sessionId ?? this.sessionId,
|
||||
pin: pin ?? this.pin,
|
||||
role: role ?? this.role,
|
||||
status: status ?? this.status,
|
||||
connectedDevice: clearConnectedDevice ? null : (connectedDevice ?? this.connectedDevice),
|
||||
createdAt: createdAt ?? this.createdAt,
|
||||
errorMessage: clearErrorMessage ? null : (errorMessage ?? this.errorMessage),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'remote_session.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// JsonSerializableGenerator
|
||||
// **************************************************************************
|
||||
|
||||
RemoteDevice _$RemoteDeviceFromJson(Map<String, dynamic> json) => RemoteDevice(
|
||||
id: json['id'] as String,
|
||||
name: json['name'] as String,
|
||||
platform: json['platform'] as String,
|
||||
connectedAt: json['connectedAt'] == null ? null : 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) => <String, dynamic>{
|
||||
'id': instance.id,
|
||||
'name': instance.name,
|
||||
'platform': instance.platform,
|
||||
'connectedAt': instance.connectedAt.toIso8601String(),
|
||||
'capabilities': instance.capabilities,
|
||||
};
|
||||
|
||||
RemoteSession _$RemoteSessionFromJson(Map<String, dynamic> json) => RemoteSession(
|
||||
sessionId: json['sessionId'] as String,
|
||||
pin: json['pin'] as String,
|
||||
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>{
|
||||
'sessionId': instance.sessionId,
|
||||
'pin': instance.pin,
|
||||
'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 = {
|
||||
RemoteSessionStatus.disconnected: 'disconnected',
|
||||
RemoteSessionStatus.connecting: 'connecting',
|
||||
RemoteSessionStatus.connected: 'connected',
|
||||
RemoteSessionStatus.reconnecting: 'reconnecting',
|
||||
RemoteSessionStatus.error: 'error',
|
||||
};
|
||||
@@ -0,0 +1,54 @@
|
||||
import 'package:json_annotation/json_annotation.dart';
|
||||
|
||||
part 'trusted_device.g.dart';
|
||||
|
||||
@JsonSerializable()
|
||||
class TrustedDevice {
|
||||
final String peerId;
|
||||
final String deviceName;
|
||||
final String platform;
|
||||
final DateTime firstConnected;
|
||||
final DateTime lastConnected;
|
||||
final bool isApproved;
|
||||
|
||||
TrustedDevice({
|
||||
required this.peerId,
|
||||
required this.deviceName,
|
||||
required this.platform,
|
||||
DateTime? firstConnected,
|
||||
DateTime? lastConnected,
|
||||
this.isApproved = false,
|
||||
}) : firstConnected = firstConnected ?? DateTime.now(),
|
||||
lastConnected = lastConnected ?? DateTime.now();
|
||||
|
||||
factory TrustedDevice.fromJson(Map<String, dynamic> json) => _$TrustedDeviceFromJson(json);
|
||||
|
||||
Map<String, dynamic> toJson() => _$TrustedDeviceToJson(this);
|
||||
|
||||
TrustedDevice copyWith({
|
||||
String? peerId,
|
||||
String? deviceName,
|
||||
String? platform,
|
||||
DateTime? firstConnected,
|
||||
DateTime? lastConnected,
|
||||
bool? isApproved,
|
||||
}) {
|
||||
return TrustedDevice(
|
||||
peerId: peerId ?? this.peerId,
|
||||
deviceName: deviceName ?? this.deviceName,
|
||||
platform: platform ?? this.platform,
|
||||
firstConnected: firstConnected ?? this.firstConnected,
|
||||
lastConnected: lastConnected ?? this.lastConnected,
|
||||
isApproved: isApproved ?? this.isApproved,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
if (identical(this, other)) return true;
|
||||
return other is TrustedDevice && other.peerId == peerId;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => peerId.hashCode;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'trusted_device.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// JsonSerializableGenerator
|
||||
// **************************************************************************
|
||||
|
||||
TrustedDevice _$TrustedDeviceFromJson(Map<String, dynamic> json) => TrustedDevice(
|
||||
peerId: json['peerId'] as String,
|
||||
deviceName: json['deviceName'] as String,
|
||||
platform: json['platform'] as String,
|
||||
firstConnected: json['firstConnected'] == null ? null : DateTime.parse(json['firstConnected'] as String),
|
||||
lastConnected: json['lastConnected'] == null ? null : DateTime.parse(json['lastConnected'] as String),
|
||||
isApproved: json['isApproved'] as bool? ?? false,
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$TrustedDeviceToJson(TrustedDevice instance) => <String, dynamic>{
|
||||
'peerId': instance.peerId,
|
||||
'deviceName': instance.deviceName,
|
||||
'platform': instance.platform,
|
||||
'firstConnected': instance.firstConnected.toIso8601String(),
|
||||
'lastConnected': instance.lastConnected.toIso8601String(),
|
||||
'isApproved': instance.isApproved,
|
||||
};
|
||||
@@ -0,0 +1,556 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:device_info_plus/device_info_plus.dart';
|
||||
|
||||
import '../models/companion_remote/remote_command.dart';
|
||||
import '../models/companion_remote/remote_command_type.dart';
|
||||
import '../models/companion_remote/remote_session.dart';
|
||||
import '../models/companion_remote/trusted_device.dart';
|
||||
import '../services/companion_remote/companion_remote_peer_service.dart';
|
||||
import '../models/companion_remote/recent_remote_session.dart';
|
||||
import '../services/companion_remote/companion_remote_discovery_service.dart';
|
||||
import '../services/storage_service.dart';
|
||||
import '../utils/app_logger.dart';
|
||||
|
||||
typedef CommandReceivedCallback = void Function(RemoteCommand command);
|
||||
typedef DeviceApprovalCallback = Future<bool> Function(RemoteDevice device);
|
||||
|
||||
class CompanionRemoteProvider with ChangeNotifier {
|
||||
RemoteSession? _session;
|
||||
CompanionRemotePeerService? _peerService;
|
||||
CompanionRemoteDiscoveryService? _discoveryService;
|
||||
String _deviceName = 'Unknown Device';
|
||||
String _platform = 'unknown';
|
||||
final List<TrustedDevice> _trustedDevices = [];
|
||||
final List<RecentRemoteSession> _recentSessions = [];
|
||||
bool _isPlayerActive = false;
|
||||
|
||||
static const String _storageKey = 'companion_remote_trusted_devices';
|
||||
static const String _lastDeviceKey = 'companion_remote_last_device';
|
||||
static const int _maxReconnectAttempts = 5;
|
||||
|
||||
Timer? _reconnectTimer;
|
||||
int _reconnectAttempts = 0;
|
||||
bool _intentionalDisconnect = false;
|
||||
String? _lastSessionId;
|
||||
String? _lastPin;
|
||||
String? _lastHostAddress;
|
||||
|
||||
int get reconnectAttempts => _reconnectAttempts;
|
||||
|
||||
StreamSubscription<RemoteCommand>? _commandSubscription;
|
||||
StreamSubscription<RemoteDevice>? _deviceConnectedSubscription;
|
||||
StreamSubscription<void>? _deviceDisconnectedSubscription;
|
||||
StreamSubscription<RemotePeerError>? _errorSubscription;
|
||||
StreamSubscription<RemoteSessionStatus>? _statusSubscription;
|
||||
StreamSubscription<List<RecentRemoteSession>>? _recentSessionsSubscription;
|
||||
|
||||
CommandReceivedCallback? onCommandReceived;
|
||||
DeviceApprovalCallback? onDeviceApprovalRequired;
|
||||
|
||||
bool get isInSession => _session != null && _session!.status != RemoteSessionStatus.disconnected;
|
||||
bool get isHost => _session?.isHost ?? false;
|
||||
bool get isRemote => _session?.isRemote ?? false;
|
||||
bool get isConnected => _session?.isConnected ?? false;
|
||||
RemoteSession? get session => _session;
|
||||
RemoteSessionStatus get status => _session?.status ?? RemoteSessionStatus.disconnected;
|
||||
String? get sessionId => _session?.sessionId;
|
||||
String? get pin => _session?.pin;
|
||||
RemoteDevice? get connectedDevice => _session?.connectedDevice;
|
||||
List<TrustedDevice> get trustedDevices => List.unmodifiable(_trustedDevices);
|
||||
List<RecentRemoteSession> get recentSessions => List.unmodifiable(_recentSessions);
|
||||
bool get isPlayerActive => _isPlayerActive;
|
||||
|
||||
CompanionRemoteProvider() {
|
||||
_initializeDeviceInfo();
|
||||
_loadTrustedDevices();
|
||||
}
|
||||
|
||||
Future<void> _initializeDeviceInfo() async {
|
||||
final deviceInfo = DeviceInfoPlugin();
|
||||
|
||||
try {
|
||||
if (Platform.isAndroid) {
|
||||
final androidInfo = await deviceInfo.androidInfo;
|
||||
_deviceName = '${androidInfo.brand} ${androidInfo.model}';
|
||||
_platform = 'Android';
|
||||
} else if (Platform.isIOS) {
|
||||
final iosInfo = await deviceInfo.iosInfo;
|
||||
_deviceName = iosInfo.name;
|
||||
_platform = 'iOS';
|
||||
} else if (Platform.isMacOS) {
|
||||
final macInfo = await deviceInfo.macOsInfo;
|
||||
_deviceName = macInfo.computerName;
|
||||
_platform = 'macOS';
|
||||
} else if (Platform.isWindows) {
|
||||
final windowsInfo = await deviceInfo.windowsInfo;
|
||||
_deviceName = windowsInfo.computerName;
|
||||
_platform = 'Windows';
|
||||
} else if (Platform.isLinux) {
|
||||
final linuxInfo = await deviceInfo.linuxInfo;
|
||||
_deviceName = linuxInfo.name;
|
||||
_platform = 'Linux';
|
||||
}
|
||||
} catch (e) {
|
||||
appLogger.e('CompanionRemote: Failed to get device info', error: e);
|
||||
_deviceName = 'Unknown Device';
|
||||
_platform = Platform.operatingSystem;
|
||||
}
|
||||
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void _setupPeerServiceListeners() {
|
||||
_commandSubscription = _peerService!.onCommandReceived.listen(
|
||||
(command) {
|
||||
appLogger.d('CompanionRemote: Command received: ${command.type}');
|
||||
|
||||
if (command.type == RemoteCommandType.deviceInfo) {
|
||||
_handleDeviceInfo(command);
|
||||
} else if (command.type == RemoteCommandType.syncState) {
|
||||
_handleSyncState(command);
|
||||
} else if (command.type == RemoteCommandType.ping ||
|
||||
command.type == RemoteCommandType.pong ||
|
||||
command.type == RemoteCommandType.ack) {
|
||||
// Don't call callback for these
|
||||
} else {
|
||||
onCommandReceived?.call(command);
|
||||
}
|
||||
},
|
||||
onError: (error) {
|
||||
appLogger.e('CompanionRemote: Stream error', error: error);
|
||||
},
|
||||
);
|
||||
|
||||
_deviceConnectedSubscription = _peerService!.onDeviceConnected.listen((device) async {
|
||||
appLogger.d('CompanionRemote: Device connected: ${device.name}');
|
||||
_session = _session?.copyWith(status: RemoteSessionStatus.connected, connectedDevice: device);
|
||||
notifyListeners();
|
||||
|
||||
await addTrustedDevice(device, requireApproval: isHost);
|
||||
});
|
||||
|
||||
_deviceDisconnectedSubscription = _peerService!.onDeviceDisconnected.listen((_) {
|
||||
appLogger.d('CompanionRemote: Device disconnected (intentional: $_intentionalDisconnect)');
|
||||
if (_intentionalDisconnect) {
|
||||
_session = _session?.copyWith(
|
||||
status: RemoteSessionStatus.disconnected,
|
||||
clearConnectedDevice: true,
|
||||
);
|
||||
notifyListeners();
|
||||
} else if (isHost) {
|
||||
// Host keeps the server running — the client will reconnect on its own
|
||||
_session = _session?.copyWith(
|
||||
status: RemoteSessionStatus.reconnecting,
|
||||
clearConnectedDevice: true,
|
||||
clearErrorMessage: true,
|
||||
);
|
||||
notifyListeners();
|
||||
appLogger.d('CompanionRemote: Host waiting for client to reconnect');
|
||||
} else {
|
||||
_session = _session?.copyWith(status: RemoteSessionStatus.reconnecting);
|
||||
notifyListeners();
|
||||
_scheduleReconnect();
|
||||
}
|
||||
});
|
||||
|
||||
_errorSubscription = _peerService!.onError.listen((error) {
|
||||
appLogger.e('CompanionRemote: Error: ${error.message}');
|
||||
_session = _session?.copyWith(status: RemoteSessionStatus.error, errorMessage: error.message);
|
||||
notifyListeners();
|
||||
});
|
||||
|
||||
_statusSubscription = _peerService!.onConnectionStateChanged.listen((status) {
|
||||
appLogger.d('CompanionRemote: Status changed: $status');
|
||||
_session = _session?.copyWith(status: status);
|
||||
notifyListeners();
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _handleDeviceInfo(RemoteCommand command) async {
|
||||
if (command.data != null) {
|
||||
final id = command.data!['id'] as String? ?? 'unknown';
|
||||
final name = command.data!['name'] as String? ?? 'Unknown Device';
|
||||
final platform = command.data!['platform'] as String? ?? 'unknown';
|
||||
final role = command.data!['role'] as String?;
|
||||
|
||||
appLogger.d('CompanionRemote: Device info - name: $name, platform: $platform, role: $role');
|
||||
|
||||
final device = RemoteDevice(id: id, name: name, platform: platform);
|
||||
|
||||
_session = _session?.copyWith(connectedDevice: device);
|
||||
notifyListeners();
|
||||
|
||||
// Save to recent sessions now that we have the remote device's real identity
|
||||
await _addToRecentSessions();
|
||||
}
|
||||
}
|
||||
|
||||
void _handleSyncState(RemoteCommand command) {
|
||||
final playerActive = command.data?['playerActive'] as bool? ?? false;
|
||||
if (_isPlayerActive != playerActive) {
|
||||
_isPlayerActive = playerActive;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
void _cleanupSubscriptions() {
|
||||
_commandSubscription?.cancel();
|
||||
_commandSubscription = null;
|
||||
_deviceConnectedSubscription?.cancel();
|
||||
_deviceConnectedSubscription = null;
|
||||
_deviceDisconnectedSubscription?.cancel();
|
||||
_deviceDisconnectedSubscription = null;
|
||||
_errorSubscription?.cancel();
|
||||
_errorSubscription = null;
|
||||
_statusSubscription?.cancel();
|
||||
_statusSubscription = null;
|
||||
}
|
||||
|
||||
Future<({String sessionId, String pin, String address})> createSession() async {
|
||||
await leaveSession();
|
||||
|
||||
appLogger.d('CompanionRemote: Creating session as host');
|
||||
|
||||
_peerService = CompanionRemotePeerService();
|
||||
_setupPeerServiceListeners();
|
||||
|
||||
try {
|
||||
final result = await _peerService!.createSession(_deviceName, _platform);
|
||||
|
||||
_session = RemoteSession(
|
||||
sessionId: result.sessionId,
|
||||
pin: result.pin,
|
||||
role: RemoteSessionRole.host,
|
||||
status: RemoteSessionStatus.connected,
|
||||
);
|
||||
|
||||
notifyListeners();
|
||||
appLogger.d(
|
||||
'CompanionRemote: Session created - ID: ${result.sessionId}, PIN: ${result.pin}, Address: ${result.address}',
|
||||
);
|
||||
|
||||
return result;
|
||||
} catch (e) {
|
||||
appLogger.e('CompanionRemote: Failed to create session', error: e);
|
||||
_session = RemoteSession(
|
||||
sessionId: '',
|
||||
pin: '',
|
||||
role: RemoteSessionRole.host,
|
||||
status: RemoteSessionStatus.error,
|
||||
errorMessage: e.toString(),
|
||||
);
|
||||
notifyListeners();
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> joinSession(String sessionId, String pin, String hostAddress) async {
|
||||
await leaveSession();
|
||||
|
||||
_lastSessionId = sessionId;
|
||||
_lastPin = pin;
|
||||
_lastHostAddress = hostAddress;
|
||||
|
||||
appLogger.d('CompanionRemote: Joining session - ID: $sessionId, Host: $hostAddress');
|
||||
|
||||
_peerService = CompanionRemotePeerService();
|
||||
_setupPeerServiceListeners();
|
||||
|
||||
_session = RemoteSession(
|
||||
sessionId: sessionId,
|
||||
pin: pin,
|
||||
role: RemoteSessionRole.remote,
|
||||
status: RemoteSessionStatus.connecting,
|
||||
);
|
||||
notifyListeners();
|
||||
|
||||
try {
|
||||
await _peerService!.joinSession(sessionId, pin, _deviceName, _platform, hostAddress);
|
||||
|
||||
_session = _session?.copyWith(status: RemoteSessionStatus.connected);
|
||||
notifyListeners();
|
||||
appLogger.d('CompanionRemote: Successfully joined session');
|
||||
} catch (e) {
|
||||
appLogger.e('CompanionRemote: Failed to join session', error: e);
|
||||
_session = _session?.copyWith(status: RemoteSessionStatus.error, errorMessage: e.toString());
|
||||
notifyListeners();
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
void sendCommand(RemoteCommandType type, {Map<String, dynamic>? data}) {
|
||||
if (_peerService == null || !isConnected) {
|
||||
appLogger.w('CompanionRemote: Cannot send command - not connected');
|
||||
return;
|
||||
}
|
||||
|
||||
appLogger.d('CompanionRemote: Sending command $type');
|
||||
_peerService!.sendCommand(RemoteCommand(type: type, data: data));
|
||||
}
|
||||
|
||||
void _scheduleReconnect() {
|
||||
if (_reconnectAttempts >= _maxReconnectAttempts) {
|
||||
appLogger.w('CompanionRemote: Max reconnect attempts reached');
|
||||
_session = _session?.copyWith(
|
||||
status: RemoteSessionStatus.error,
|
||||
errorMessage: 'Connection lost after $_maxReconnectAttempts attempts',
|
||||
);
|
||||
_reconnectAttempts = 0;
|
||||
notifyListeners();
|
||||
return;
|
||||
}
|
||||
|
||||
final delay = Duration(seconds: 1 << _reconnectAttempts); // 1s, 2s, 4s, 8s, 16s
|
||||
_reconnectAttempts++;
|
||||
appLogger.d('CompanionRemote: Reconnect attempt $_reconnectAttempts in ${delay.inSeconds}s');
|
||||
|
||||
_reconnectTimer?.cancel();
|
||||
_reconnectTimer = Timer(delay, _attemptReconnect);
|
||||
}
|
||||
|
||||
Future<void> _attemptReconnect() async {
|
||||
if (_lastSessionId == null || _lastPin == null || _lastHostAddress == null) {
|
||||
appLogger.w('CompanionRemote: No stored credentials for reconnect');
|
||||
_session = _session?.copyWith(status: RemoteSessionStatus.error, errorMessage: 'Connection lost');
|
||||
notifyListeners();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
appLogger.d('CompanionRemote: Attempting reconnect...');
|
||||
// Clean up old peer service without triggering intentional disconnect
|
||||
_cleanupSubscriptions();
|
||||
await _peerService?.disconnect();
|
||||
|
||||
_peerService = CompanionRemotePeerService();
|
||||
_setupPeerServiceListeners();
|
||||
|
||||
await _peerService!.joinSession(_lastSessionId!, _lastPin!, _deviceName, _platform, _lastHostAddress!);
|
||||
|
||||
_session = _session?.copyWith(status: RemoteSessionStatus.connected, clearErrorMessage: true);
|
||||
_reconnectAttempts = 0;
|
||||
notifyListeners();
|
||||
appLogger.d('CompanionRemote: Reconnected successfully');
|
||||
} catch (e) {
|
||||
appLogger.e('CompanionRemote: Reconnect failed', error: e);
|
||||
if (_session?.status == RemoteSessionStatus.reconnecting) {
|
||||
_scheduleReconnect();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Immediately retry reconnection, skipping the backoff wait
|
||||
void retryReconnectNow() {
|
||||
_reconnectTimer?.cancel();
|
||||
_reconnectAttempts = 0;
|
||||
_attemptReconnect();
|
||||
}
|
||||
|
||||
/// Cancel ongoing reconnection attempts
|
||||
void cancelReconnect() {
|
||||
_reconnectTimer?.cancel();
|
||||
_reconnectAttempts = 0;
|
||||
_session = _session?.copyWith(
|
||||
status: RemoteSessionStatus.disconnected,
|
||||
clearConnectedDevice: true,
|
||||
);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> leaveSession() async {
|
||||
_intentionalDisconnect = true;
|
||||
_reconnectTimer?.cancel();
|
||||
_reconnectAttempts = 0;
|
||||
|
||||
if (_peerService != null) {
|
||||
appLogger.d('CompanionRemote: Leaving session');
|
||||
await _peerService!.disconnect();
|
||||
_peerService = null;
|
||||
}
|
||||
|
||||
_cleanupSubscriptions();
|
||||
|
||||
_session = null;
|
||||
_isPlayerActive = false;
|
||||
_intentionalDisconnect = false;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> _loadTrustedDevices() async {
|
||||
try {
|
||||
final storage = await StorageService.getInstance();
|
||||
final json = storage.prefs.getString(_storageKey);
|
||||
if (json != null) {
|
||||
final List<dynamic> list = jsonDecode(json);
|
||||
_trustedDevices.clear();
|
||||
_trustedDevices.addAll(list.map((e) => TrustedDevice.fromJson(e as Map<String, dynamic>)));
|
||||
appLogger.d('CompanionRemote: Loaded ${_trustedDevices.length} trusted devices');
|
||||
}
|
||||
} catch (e) {
|
||||
appLogger.e('CompanionRemote: Failed to load trusted devices', error: e);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _saveTrustedDevices() async {
|
||||
try {
|
||||
final storage = await StorageService.getInstance();
|
||||
final json = jsonEncode(_trustedDevices.map((e) => e.toJson()).toList());
|
||||
await storage.prefs.setString(_storageKey, json);
|
||||
appLogger.d('CompanionRemote: Saved ${_trustedDevices.length} trusted devices');
|
||||
} catch (e) {
|
||||
appLogger.e('CompanionRemote: Failed to save trusted devices', error: e);
|
||||
}
|
||||
}
|
||||
|
||||
bool isDeviceTrusted(String peerId) {
|
||||
return _trustedDevices.any((d) => d.peerId == peerId && d.isApproved);
|
||||
}
|
||||
|
||||
Future<void> addTrustedDevice(RemoteDevice device, {bool requireApproval = true}) async {
|
||||
final existing = _trustedDevices.where((d) => d.peerId == device.id).firstOrNull;
|
||||
|
||||
if (existing != null) {
|
||||
final updated = existing.copyWith(
|
||||
deviceName: device.name,
|
||||
platform: device.platform,
|
||||
lastConnected: DateTime.now(),
|
||||
isApproved: !requireApproval || existing.isApproved,
|
||||
);
|
||||
_trustedDevices.remove(existing);
|
||||
_trustedDevices.add(updated);
|
||||
} else {
|
||||
bool approved = !requireApproval;
|
||||
|
||||
if (requireApproval && onDeviceApprovalRequired != null) {
|
||||
approved = await onDeviceApprovalRequired!(device);
|
||||
}
|
||||
|
||||
_trustedDevices.add(
|
||||
TrustedDevice(peerId: device.id, deviceName: device.name, platform: device.platform, isApproved: approved),
|
||||
);
|
||||
}
|
||||
|
||||
await _saveTrustedDevices();
|
||||
|
||||
if (isRemote) {
|
||||
final storage = await StorageService.getInstance();
|
||||
await storage.prefs.setString(_lastDeviceKey, device.id);
|
||||
}
|
||||
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> removeTrustedDevice(String peerId) async {
|
||||
_trustedDevices.removeWhere((d) => d.peerId == peerId);
|
||||
await _saveTrustedDevices();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> approveTrustedDevice(String peerId) async {
|
||||
final device = _trustedDevices.where((d) => d.peerId == peerId).firstOrNull;
|
||||
if (device != null) {
|
||||
final updated = device.copyWith(isApproved: true);
|
||||
_trustedDevices.remove(device);
|
||||
_trustedDevices.add(updated);
|
||||
await _saveTrustedDevices();
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
Future<String?> getLastConnectedDevicePeerId() async {
|
||||
final storage = await StorageService.getInstance();
|
||||
return storage.prefs.getString(_lastDeviceKey);
|
||||
}
|
||||
|
||||
/// Load recent sessions
|
||||
Future<void> loadRecentSessions() async {
|
||||
try {
|
||||
// Dispose previous discovery service and subscription to avoid leaks
|
||||
_recentSessionsSubscription?.cancel();
|
||||
_recentSessionsSubscription = null;
|
||||
_discoveryService?.dispose();
|
||||
|
||||
_discoveryService = CompanionRemoteDiscoveryService();
|
||||
|
||||
// Listen for recent sessions updates
|
||||
_recentSessionsSubscription = _discoveryService!.recentSessions.listen((sessions) {
|
||||
_recentSessions.clear();
|
||||
_recentSessions.addAll(sessions);
|
||||
notifyListeners();
|
||||
});
|
||||
|
||||
// Initial load happens in constructor, just notify
|
||||
_recentSessions.clear();
|
||||
_recentSessions.addAll(_discoveryService!.currentSessions);
|
||||
notifyListeners();
|
||||
|
||||
appLogger.d('CompanionRemote: Loaded ${_recentSessions.length} recent sessions');
|
||||
} catch (e) {
|
||||
appLogger.e('CompanionRemote: Failed to load recent sessions', error: e);
|
||||
}
|
||||
}
|
||||
|
||||
/// Add current session to recent list (called after successful connection)
|
||||
Future<void> _addToRecentSessions() async {
|
||||
if (_session == null || _session!.sessionId.isEmpty) return;
|
||||
|
||||
// For mobile (remote role), save the connected desktop device
|
||||
// For desktop (host role), this doesn't really apply but save connected mobile device
|
||||
final deviceToSave = _session!.connectedDevice;
|
||||
if (deviceToSave == null) {
|
||||
appLogger.w('CompanionRemote: No connected device to save to recent sessions');
|
||||
return;
|
||||
}
|
||||
|
||||
final recentSession = RecentRemoteSession(
|
||||
sessionId: _session!.sessionId,
|
||||
pin: _session!.pin,
|
||||
deviceName: deviceToSave.name,
|
||||
platform: deviceToSave.platform,
|
||||
lastConnected: DateTime.now(),
|
||||
hostAddress: _peerService?.hostAddress,
|
||||
);
|
||||
|
||||
if (_discoveryService != null) {
|
||||
await _discoveryService!.addRecentSession(recentSession);
|
||||
}
|
||||
}
|
||||
|
||||
/// Connect to a recent session
|
||||
Future<void> connectToRecentSession(RecentRemoteSession session) async {
|
||||
if (session.hostAddress == null) {
|
||||
throw const RemotePeerError(
|
||||
type: RemotePeerErrorType.invalidSession,
|
||||
message: 'No host address available for this session. Please scan a new QR code.',
|
||||
);
|
||||
}
|
||||
await joinSession(session.sessionId, session.pin, session.hostAddress!);
|
||||
}
|
||||
|
||||
/// Remove a recent session
|
||||
Future<void> removeRecentSession(String sessionId) async {
|
||||
if (_discoveryService != null) {
|
||||
await _discoveryService!.removeRecentSession(sessionId);
|
||||
}
|
||||
}
|
||||
|
||||
/// Clear all recent sessions
|
||||
Future<void> clearRecentSessions() async {
|
||||
if (_discoveryService != null) {
|
||||
await _discoveryService!.clearRecentSessions();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_reconnectTimer?.cancel();
|
||||
leaveSession();
|
||||
_recentSessionsSubscription?.cancel();
|
||||
_discoveryService?.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,669 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../../models/companion_remote/remote_command_type.dart';
|
||||
import '../../models/companion_remote/remote_session.dart';
|
||||
import '../../providers/companion_remote_provider.dart';
|
||||
import '../../utils/platform_detector.dart';
|
||||
import '../../utils/app_logger.dart';
|
||||
import 'pairing_screen.dart';
|
||||
|
||||
class MobileRemoteScreen extends StatefulWidget {
|
||||
const MobileRemoteScreen({super.key});
|
||||
|
||||
@override
|
||||
State<MobileRemoteScreen> createState() => _MobileRemoteScreenState();
|
||||
}
|
||||
|
||||
class _MobileRemoteScreenState extends State<MobileRemoteScreen> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Companion Remote'),
|
||||
actions: [
|
||||
Consumer<CompanionRemoteProvider>(
|
||||
builder: (context, provider, child) {
|
||||
if (provider.isConnected) {
|
||||
return IconButton(
|
||||
icon: const Icon(Icons.link_off),
|
||||
onPressed: () async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Disconnect'),
|
||||
content: const Text('Do you want to disconnect from the remote session?'),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('Cancel')),
|
||||
TextButton(onPressed: () => Navigator.pop(context, true), child: const Text('Disconnect')),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (confirmed == true && context.mounted) {
|
||||
await context.read<CompanionRemoteProvider>().leaveSession();
|
||||
}
|
||||
},
|
||||
tooltip: 'Disconnect',
|
||||
);
|
||||
}
|
||||
return const SizedBox.shrink();
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
body: Consumer<CompanionRemoteProvider>(
|
||||
builder: (context, provider, child) {
|
||||
if (provider.status == RemoteSessionStatus.reconnecting) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const CircularProgressIndicator(),
|
||||
const SizedBox(height: 24),
|
||||
Text('Reconnecting...', style: Theme.of(context).textTheme.titleLarge),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Attempt ${provider.reconnectAttempts} of 5',
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: Colors.grey),
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
OutlinedButton(onPressed: () => provider.cancelReconnect(), child: const Text('Cancel')),
|
||||
const SizedBox(width: 16),
|
||||
FilledButton(onPressed: () => provider.retryReconnectNow(), child: const Text('Retry Now')),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (!provider.isConnected) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(Icons.phonelink_off, size: 64, color: Colors.grey),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
provider.status == RemoteSessionStatus.error
|
||||
? provider.session?.errorMessage ?? 'Connection error'
|
||||
: 'Not connected',
|
||||
style: const TextStyle(fontSize: 20, color: Colors.grey),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
FilledButton.icon(
|
||||
onPressed: () {
|
||||
Navigator.push(context, MaterialPageRoute(builder: (context) => const PairingScreen()));
|
||||
},
|
||||
icon: const Icon(Icons.link),
|
||||
label: const Text('Connect to Device'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return const _RemoteControlLayout();
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _RemoteControlLayout extends StatelessWidget {
|
||||
const _RemoteControlLayout();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (PlatformDetector.isDesktop(context)) {
|
||||
return Center(
|
||||
child: ConstrainedBox(constraints: const BoxConstraints(maxWidth: 400), child: const _RemoteControlContent()),
|
||||
);
|
||||
}
|
||||
|
||||
return const _RemoteControlContent();
|
||||
}
|
||||
}
|
||||
|
||||
class _RemoteControlContent extends StatefulWidget {
|
||||
const _RemoteControlContent();
|
||||
|
||||
@override
|
||||
State<_RemoteControlContent> createState() => _RemoteControlContentState();
|
||||
}
|
||||
|
||||
class _RemoteControlContentState extends State<_RemoteControlContent> {
|
||||
int _selectedTab = 0;
|
||||
|
||||
void _showSearchSheet({bool switchToSearchTab = false}) {
|
||||
if (switchToSearchTab) {
|
||||
_sendCommand(RemoteCommandType.tabSearch);
|
||||
}
|
||||
final provider = context.read<CompanionRemoteProvider>();
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
builder: (_) => _SearchBottomSheet(provider: provider),
|
||||
);
|
||||
}
|
||||
|
||||
void _sendCommand(RemoteCommandType type) {
|
||||
HapticFeedback.lightImpact();
|
||||
appLogger.d('MobileRemoteScreen: Sending command: $type');
|
||||
final provider = context.read<CompanionRemoteProvider>();
|
||||
provider.sendCommand(type);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
children: [
|
||||
Consumer<CompanionRemoteProvider>(
|
||||
builder: (context, provider, child) {
|
||||
final device = provider.connectedDevice;
|
||||
if (device == null) return const SizedBox.shrink();
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
color: Theme.of(context).colorScheme.primaryContainer,
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.computer, color: Theme.of(context).colorScheme.onPrimaryContainer),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
device.name,
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.titleMedium?.copyWith(color: Theme.of(context).colorScheme.onPrimaryContainer),
|
||||
),
|
||||
Text(
|
||||
device.platform,
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.bodySmall?.copyWith(color: Theme.of(context).colorScheme.onPrimaryContainer),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Container(
|
||||
width: 8,
|
||||
height: 8,
|
||||
decoration: BoxDecoration(color: Colors.green, shape: BoxShape.circle),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
children: [
|
||||
SegmentedButton<int>(
|
||||
showSelectedIcon: false,
|
||||
segments: const [
|
||||
ButtonSegment(value: 0, label: Text('Remote'), icon: Icon(Icons.navigation)),
|
||||
ButtonSegment(value: 1, label: Text('Play'), icon: Icon(Icons.play_arrow)),
|
||||
ButtonSegment(value: 2, label: Text('More'), icon: Icon(Icons.flash_on)),
|
||||
],
|
||||
selected: {_selectedTab},
|
||||
onSelectionChanged: (Set<int> selection) {
|
||||
setState(() {
|
||||
_selectedTab = selection.first;
|
||||
});
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
if (_selectedTab == 0) _buildNavigationTab(),
|
||||
if (_selectedTab == 1) _buildPlaybackTab(),
|
||||
if (_selectedTab == 2) _buildQuickActionsTab(),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildNavigationTab() {
|
||||
final isPlayerActive = context.watch<CompanionRemoteProvider>().isPlayerActive;
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
children: [
|
||||
_RemoteButton(icon: Icons.home, label: 'Home', onPressed: () => _sendCommand(RemoteCommandType.home)),
|
||||
_RemoteButton(icon: Icons.arrow_back, label: 'Back', onPressed: () => _sendCommand(RemoteCommandType.back)),
|
||||
_RemoteButton(
|
||||
icon: Icons.menu,
|
||||
label: 'Menu',
|
||||
onPressed: () => _sendCommand(RemoteCommandType.contextMenu),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
Center(child: _DPad(onCommand: _sendCommand)),
|
||||
if (!isPlayerActive) ...[
|
||||
const SizedBox(height: 32),
|
||||
Text('Tab Navigation', style: Theme.of(context).textTheme.titleMedium),
|
||||
const SizedBox(height: 16),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
alignment: WrapAlignment.center,
|
||||
children: [
|
||||
_RemoteChip(
|
||||
icon: Icons.explore,
|
||||
label: 'Discover',
|
||||
onPressed: () => _sendCommand(RemoteCommandType.tabDiscover),
|
||||
),
|
||||
_RemoteChip(
|
||||
icon: Icons.video_library,
|
||||
label: 'Libraries',
|
||||
onPressed: () => _sendCommand(RemoteCommandType.tabLibraries),
|
||||
),
|
||||
_RemoteChip(
|
||||
icon: Icons.search,
|
||||
label: 'Search',
|
||||
onPressed: () => _showSearchSheet(switchToSearchTab: true),
|
||||
),
|
||||
_RemoteChip(
|
||||
icon: Icons.download,
|
||||
label: 'Downloads',
|
||||
onPressed: () => _sendCommand(RemoteCommandType.tabDownloads),
|
||||
),
|
||||
_RemoteChip(
|
||||
icon: Icons.settings,
|
||||
label: 'Settings',
|
||||
onPressed: () => _sendCommand(RemoteCommandType.tabSettings),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPlaybackTab() {
|
||||
return Column(
|
||||
children: [
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
_RemoteButton(
|
||||
icon: Icons.skip_previous,
|
||||
label: 'Previous',
|
||||
onPressed: () => _sendCommand(RemoteCommandType.previousTrack),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
_RemoteButton(
|
||||
icon: Icons.play_arrow,
|
||||
label: 'Play/Pause',
|
||||
size: 64,
|
||||
iconSize: 36,
|
||||
onPressed: () => _sendCommand(RemoteCommandType.playPause),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
_RemoteButton(
|
||||
icon: Icons.skip_next,
|
||||
label: 'Next',
|
||||
onPressed: () => _sendCommand(RemoteCommandType.nextTrack),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
_RemoteButton(
|
||||
icon: Icons.replay_10,
|
||||
label: 'Seek Back',
|
||||
onPressed: () => _sendCommand(RemoteCommandType.seekBackward),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
_RemoteButton(icon: Icons.stop, label: 'Stop', onPressed: () => _sendCommand(RemoteCommandType.stop)),
|
||||
const SizedBox(width: 16),
|
||||
_RemoteButton(
|
||||
icon: Icons.forward_10,
|
||||
label: 'Seek Fwd',
|
||||
onPressed: () => _sendCommand(RemoteCommandType.seekForward),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
Text('Volume', style: Theme.of(context).textTheme.titleMedium),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
_RemoteButton(
|
||||
icon: Icons.volume_off,
|
||||
label: 'Mute',
|
||||
onPressed: () => _sendCommand(RemoteCommandType.volumeMute),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
_RemoteButton(
|
||||
icon: Icons.volume_down,
|
||||
label: 'Down',
|
||||
onPressed: () => _sendCommand(RemoteCommandType.volumeDown),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
_RemoteButton(
|
||||
icon: Icons.volume_up,
|
||||
label: 'Up',
|
||||
onPressed: () => _sendCommand(RemoteCommandType.volumeUp),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildQuickActionsTab() {
|
||||
final isPlayerActive = context.watch<CompanionRemoteProvider>().isPlayerActive;
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
const SizedBox(height: 16),
|
||||
Wrap(
|
||||
spacing: 12,
|
||||
runSpacing: 12,
|
||||
alignment: WrapAlignment.center,
|
||||
children: [
|
||||
if (!isPlayerActive)
|
||||
_RemoteCard(icon: Icons.search, label: 'Search', onPressed: _showSearchSheet),
|
||||
if (isPlayerActive) ...[
|
||||
_RemoteCard(
|
||||
icon: Icons.fullscreen,
|
||||
label: 'Fullscreen',
|
||||
onPressed: () => _sendCommand(RemoteCommandType.fullscreen),
|
||||
),
|
||||
_RemoteCard(
|
||||
icon: Icons.subtitles,
|
||||
label: 'Subtitles',
|
||||
onPressed: () => _sendCommand(RemoteCommandType.subtitles),
|
||||
),
|
||||
_RemoteCard(
|
||||
icon: Icons.audiotrack,
|
||||
label: 'Audio',
|
||||
onPressed: () => _sendCommand(RemoteCommandType.audioTracks),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _DPad extends StatelessWidget {
|
||||
final Function(RemoteCommandType) onCommand;
|
||||
|
||||
const _DPad({required this.onCommand});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
const size = 72.0;
|
||||
const gap = 8.0;
|
||||
const total = size * 3 + gap * 2;
|
||||
|
||||
return SizedBox(
|
||||
width: total,
|
||||
height: total,
|
||||
child: Stack(
|
||||
children: [
|
||||
Positioned(
|
||||
left: size + gap,
|
||||
top: 0,
|
||||
child: _DPadButton(
|
||||
icon: Icons.arrow_drop_up,
|
||||
onPressed: () => onCommand(RemoteCommandType.dpadUp),
|
||||
size: size,
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
left: size + gap,
|
||||
bottom: 0,
|
||||
child: _DPadButton(
|
||||
icon: Icons.arrow_drop_down,
|
||||
onPressed: () => onCommand(RemoteCommandType.dpadDown),
|
||||
size: size,
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
left: 0,
|
||||
top: size + gap,
|
||||
child: _DPadButton(
|
||||
icon: Icons.arrow_left,
|
||||
onPressed: () => onCommand(RemoteCommandType.dpadLeft),
|
||||
size: size,
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
right: 0,
|
||||
top: size + gap,
|
||||
child: _DPadButton(
|
||||
icon: Icons.arrow_right,
|
||||
onPressed: () => onCommand(RemoteCommandType.dpadRight),
|
||||
size: size,
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
left: size + gap,
|
||||
top: size + gap,
|
||||
child: _DPadButton(
|
||||
icon: Icons.check,
|
||||
label: 'OK',
|
||||
onPressed: () => onCommand(RemoteCommandType.select),
|
||||
size: size,
|
||||
isPrimary: true,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _DPadButton extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final String? label;
|
||||
final VoidCallback onPressed;
|
||||
final double size;
|
||||
final bool isPrimary;
|
||||
|
||||
const _DPadButton({
|
||||
required this.icon,
|
||||
this.label,
|
||||
required this.onPressed,
|
||||
required this.size,
|
||||
this.isPrimary = false,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SizedBox(
|
||||
width: size,
|
||||
height: size,
|
||||
child: ElevatedButton(
|
||||
onPressed: () {
|
||||
HapticFeedback.lightImpact();
|
||||
onPressed();
|
||||
},
|
||||
style: ElevatedButton.styleFrom(
|
||||
padding: EdgeInsets.zero,
|
||||
shape: const CircleBorder(),
|
||||
backgroundColor: isPrimary ? Theme.of(context).colorScheme.primary : null,
|
||||
foregroundColor: isPrimary ? Theme.of(context).colorScheme.onPrimary : null,
|
||||
),
|
||||
child: label != null
|
||||
? Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(icon, size: 24),
|
||||
const SizedBox(height: 2),
|
||||
Text(label!, style: const TextStyle(fontSize: 10)),
|
||||
],
|
||||
)
|
||||
: Icon(icon, size: 36),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _RemoteButton extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final String label;
|
||||
final VoidCallback onPressed;
|
||||
final double size;
|
||||
final double iconSize;
|
||||
|
||||
const _RemoteButton({
|
||||
required this.icon,
|
||||
required this.label,
|
||||
required this.onPressed,
|
||||
this.size = 56,
|
||||
this.iconSize = 24,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: size,
|
||||
height: size,
|
||||
child: FilledButton(
|
||||
onPressed: () {
|
||||
HapticFeedback.lightImpact();
|
||||
onPressed();
|
||||
},
|
||||
style: FilledButton.styleFrom(padding: EdgeInsets.zero, shape: const CircleBorder()),
|
||||
child: Icon(icon, size: iconSize),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(label, style: Theme.of(context).textTheme.bodySmall, textAlign: TextAlign.center),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _RemoteChip extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final String label;
|
||||
final VoidCallback onPressed;
|
||||
|
||||
const _RemoteChip({required this.icon, required this.label, required this.onPressed});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ActionChip(
|
||||
avatar: Icon(icon, size: 18),
|
||||
label: Text(label),
|
||||
onPressed: () {
|
||||
HapticFeedback.lightImpact();
|
||||
onPressed();
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SearchBottomSheet extends StatefulWidget {
|
||||
final CompanionRemoteProvider provider;
|
||||
|
||||
const _SearchBottomSheet({required this.provider});
|
||||
|
||||
@override
|
||||
State<_SearchBottomSheet> createState() => _SearchBottomSheetState();
|
||||
}
|
||||
|
||||
class _SearchBottomSheetState extends State<_SearchBottomSheet> {
|
||||
final _controller = TextEditingController();
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _submit(String text) {
|
||||
final trimmed = text.trim();
|
||||
if (trimmed.isNotEmpty) {
|
||||
widget.provider.sendCommand(RemoteCommandType.search, data: {'query': trimmed});
|
||||
}
|
||||
Navigator.pop(context);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(bottom: MediaQuery.viewInsetsOf(context).bottom, left: 16, right: 16, top: 16),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
TextField(
|
||||
controller: _controller,
|
||||
autofocus: true,
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Search on desktop...',
|
||||
prefixIcon: const Icon(Icons.search),
|
||||
suffixIcon: IconButton(icon: const Icon(Icons.send), onPressed: () => _submit(_controller.text)),
|
||||
border: OutlineInputBorder(borderRadius: BorderRadius.circular(100)),
|
||||
),
|
||||
onSubmitted: _submit,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _RemoteCard extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final String label;
|
||||
final VoidCallback onPressed;
|
||||
|
||||
const _RemoteCard({required this.icon, required this.label, required this.onPressed});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SizedBox(
|
||||
width: 100,
|
||||
height: 100,
|
||||
child: Card(
|
||||
child: InkWell(
|
||||
onTap: () {
|
||||
HapticFeedback.lightImpact();
|
||||
onPressed();
|
||||
},
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(icon, size: 32),
|
||||
const SizedBox(height: 8),
|
||||
Text(label, style: Theme.of(context).textTheme.bodySmall, textAlign: TextAlign.center),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,622 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:mobile_scanner/mobile_scanner.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../../providers/companion_remote_provider.dart';
|
||||
import '../../models/companion_remote/recent_remote_session.dart';
|
||||
import '../../utils/app_logger.dart';
|
||||
|
||||
class PairingScreen extends StatefulWidget {
|
||||
const PairingScreen({super.key});
|
||||
|
||||
@override
|
||||
State<PairingScreen> createState() => _PairingScreenState();
|
||||
}
|
||||
|
||||
class _PairingScreenState extends State<PairingScreen> {
|
||||
final _hostAddressController = TextEditingController();
|
||||
final _sessionIdController = TextEditingController();
|
||||
final _pinController = TextEditingController();
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
bool _isConnecting = false;
|
||||
String? _connectingSessionId;
|
||||
bool _isDiscovering = false;
|
||||
String? _errorMessage;
|
||||
int _selectedTab = 0;
|
||||
|
||||
// QR scanner state
|
||||
MobileScannerController? _scannerController;
|
||||
String? _lastScannedCode;
|
||||
|
||||
bool get _isMobile => Platform.isAndroid || Platform.isIOS;
|
||||
|
||||
// Tab indices shift when scan tab is present
|
||||
int get _scanTabIndex => _isMobile ? 1 : -1;
|
||||
int get _manualTabIndex => _isMobile ? 2 : 1;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadRecentSessions();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_hostAddressController.dispose();
|
||||
_sessionIdController.dispose();
|
||||
_pinController.dispose();
|
||||
_scannerController?.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _loadRecentSessions() async {
|
||||
setState(() {
|
||||
_isDiscovering = true;
|
||||
_errorMessage = null;
|
||||
});
|
||||
|
||||
try {
|
||||
await context.read<CompanionRemoteProvider>().loadRecentSessions();
|
||||
setState(() {
|
||||
_isDiscovering = false;
|
||||
});
|
||||
} catch (e) {
|
||||
appLogger.e('Failed to load recent sessions', error: e);
|
||||
setState(() {
|
||||
_isDiscovering = false;
|
||||
_errorMessage = 'Failed to load recent sessions: ${e.toString()}';
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _connectToRecentSession(RecentRemoteSession session) async {
|
||||
setState(() {
|
||||
_isConnecting = true;
|
||||
_connectingSessionId = session.sessionId;
|
||||
_errorMessage = null;
|
||||
});
|
||||
|
||||
try {
|
||||
await context.read<CompanionRemoteProvider>().connectToRecentSession(session);
|
||||
|
||||
if (mounted) {
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
} catch (e) {
|
||||
appLogger.e('Failed to connect to recent session', error: e);
|
||||
setState(() {
|
||||
_isConnecting = false;
|
||||
_connectingSessionId = null;
|
||||
_errorMessage = _parseErrorMessage(e.toString());
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _connect() async {
|
||||
if (!_formKey.currentState!.validate()) {
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_isConnecting = true;
|
||||
_errorMessage = null;
|
||||
});
|
||||
|
||||
try {
|
||||
final provider = context.read<CompanionRemoteProvider>();
|
||||
await provider.joinSession(
|
||||
_sessionIdController.text.trim().toUpperCase(),
|
||||
_pinController.text.trim(),
|
||||
_hostAddressController.text.trim(),
|
||||
);
|
||||
|
||||
if (mounted) {
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
} catch (e) {
|
||||
appLogger.e('Failed to join remote session', error: e);
|
||||
setState(() {
|
||||
_isConnecting = false;
|
||||
_errorMessage = _parseErrorMessage(e.toString());
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
String _parseErrorMessage(String error) {
|
||||
if (error.contains('timeout') || error.contains('Timed out')) {
|
||||
return 'Connection timed out. Please check the session ID and PIN.';
|
||||
} else if (error.contains('Failed to connect')) {
|
||||
return 'Could not find the session. Please check your credentials.';
|
||||
}
|
||||
return 'Failed to connect: ${error.replaceAll('Exception: ', '')}';
|
||||
}
|
||||
|
||||
void _handleQrCode(String data) {
|
||||
// Debounce: don't process the same code twice
|
||||
if (data == _lastScannedCode) return;
|
||||
_lastScannedCode = data;
|
||||
|
||||
// New format: ip|port|sessionId|pin (4 parts separated by pipe)
|
||||
final parts = data.split('|');
|
||||
if (parts.length == 4) {
|
||||
final ip = parts[0];
|
||||
final port = parts[1];
|
||||
final sessionId = parts[2];
|
||||
final pin = parts[3];
|
||||
final hostAddress = '$ip:$port';
|
||||
|
||||
_scannerController?.stop();
|
||||
setState(() {
|
||||
_errorMessage = null;
|
||||
_isConnecting = true;
|
||||
});
|
||||
// Connect directly instead of going through _connect() which requires Form validation
|
||||
_connectWithCredentials(sessionId, pin, hostAddress);
|
||||
} else {
|
||||
setState(() {
|
||||
_errorMessage = 'Invalid QR code format - expected 4 parts (ip|port|sessionId|pin)';
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _connectWithCredentials(String sessionId, String pin, String hostAddress) async {
|
||||
try {
|
||||
final provider = context.read<CompanionRemoteProvider>();
|
||||
await provider.joinSession(sessionId.trim().toUpperCase(), pin.trim(), hostAddress.trim());
|
||||
|
||||
if (mounted) {
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
} catch (e) {
|
||||
appLogger.e('Failed to join remote session', error: e);
|
||||
_lastScannedCode = null; // Allow re-scanning
|
||||
setState(() {
|
||||
_isConnecting = false;
|
||||
_errorMessage = _parseErrorMessage(e.toString());
|
||||
});
|
||||
_scannerController?.start();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _pasteFromClipboard(TextEditingController controller) async {
|
||||
final data = await Clipboard.getData(Clipboard.kTextPlain);
|
||||
if (data?.text != null) {
|
||||
setState(() {
|
||||
controller.text = data!.text!;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Connect to Device'),
|
||||
actions: [
|
||||
if (_selectedTab == 0)
|
||||
IconButton(
|
||||
icon: const Icon(Icons.refresh),
|
||||
onPressed: _isDiscovering ? null : _loadRecentSessions,
|
||||
tooltip: 'Refresh',
|
||||
),
|
||||
],
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
SegmentedButton<int>(
|
||||
segments: [
|
||||
const ButtonSegment(value: 0, label: Text('Recent'), icon: Icon(Icons.history)),
|
||||
if (_isMobile)
|
||||
ButtonSegment(value: _scanTabIndex, label: const Text('Scan'), icon: const Icon(Icons.qr_code_scanner)),
|
||||
ButtonSegment(value: _manualTabIndex, label: const Text('Manual'), icon: const Icon(Icons.keyboard)),
|
||||
],
|
||||
selected: {_selectedTab},
|
||||
onSelectionChanged: (Set<int> selection) {
|
||||
setState(() {
|
||||
_selectedTab = selection.first;
|
||||
});
|
||||
},
|
||||
),
|
||||
Expanded(child: _buildTabContent()),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTabContent() {
|
||||
if (_selectedTab == 0) return _buildDiscoveryTab();
|
||||
if (_selectedTab == _scanTabIndex) return _buildScanTab();
|
||||
return _buildManualEntryTab();
|
||||
}
|
||||
|
||||
Widget _buildScanTab() {
|
||||
return Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24.0),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: MobileScanner(
|
||||
controller: _scannerController ??= MobileScannerController(),
|
||||
onDetect: (capture) {
|
||||
final barcode = capture.barcodes.firstOrNull;
|
||||
if (barcode?.rawValue != null) {
|
||||
_handleQrCode(barcode!.rawValue!);
|
||||
}
|
||||
},
|
||||
errorBuilder: (context, error, child) {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24.0),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(Icons.no_photography, size: 64, color: Colors.grey),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
error.errorCode == MobileScannerErrorCode.permissionDenied
|
||||
? 'Camera permission is required to scan QR codes.\nPlease grant camera access in your device settings.'
|
||||
: 'Could not start camera: ${error.errorDetails?.message ?? error.errorCode.name}',
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 0, 24, 24),
|
||||
child: Column(
|
||||
children: [
|
||||
Text(
|
||||
'Point your camera at the QR code shown on your desktop',
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
if (_errorMessage != null) ...[
|
||||
const SizedBox(height: 12),
|
||||
Card(
|
||||
color: Theme.of(context).colorScheme.errorContainer,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(12.0),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.error_outline, color: Theme.of(context).colorScheme.onErrorContainer),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
_errorMessage!,
|
||||
style: TextStyle(color: Theme.of(context).colorScheme.onErrorContainer),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDiscoveryTab() {
|
||||
return Consumer<CompanionRemoteProvider>(
|
||||
builder: (context, provider, child) {
|
||||
final sessions = provider.recentSessions;
|
||||
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(24.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
const Icon(Icons.history, size: 64, color: Colors.blue),
|
||||
const SizedBox(height: 24),
|
||||
Text(
|
||||
'Recent Connections',
|
||||
style: Theme.of(context).textTheme.headlineMedium,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Quickly reconnect to previously paired devices',
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
if (_isDiscovering) ...[
|
||||
const Center(child: CircularProgressIndicator()),
|
||||
const SizedBox(height: 16),
|
||||
Text('Loading...', style: Theme.of(context).textTheme.bodyMedium, textAlign: TextAlign.center),
|
||||
] else if (sessions.isEmpty) ...[
|
||||
Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24.0),
|
||||
child: Column(
|
||||
children: [
|
||||
Icon(Icons.devices_other, size: 48, color: Theme.of(context).colorScheme.outline),
|
||||
const SizedBox(height: 16),
|
||||
Text('No recent connections', style: Theme.of(context).textTheme.titleMedium),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Connect to a device using Manual entry to get started',
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
] else ...[
|
||||
...sessions.map((session) {
|
||||
final isThisConnecting = _isConnecting && _connectingSessionId == session.sessionId;
|
||||
return Card(
|
||||
margin: const EdgeInsets.only(bottom: 8),
|
||||
child: ListTile(
|
||||
leading: const Icon(Icons.computer, size: 40),
|
||||
title: Text(session.deviceName),
|
||||
subtitle: Text(
|
||||
'${session.platform}\n'
|
||||
'Session: ${session.sessionId}\n'
|
||||
'Last used: ${_formatDate(session.lastConnected)}',
|
||||
),
|
||||
isThreeLine: true,
|
||||
trailing: isThisConnecting
|
||||
? const SizedBox(width: 24, height: 24, child: CircularProgressIndicator(strokeWidth: 2))
|
||||
: const Icon(Icons.arrow_forward),
|
||||
onTap: _isConnecting ? null : () => _connectToRecentSession(session),
|
||||
onLongPress: () => _showRemoveSessionDialog(session),
|
||||
),
|
||||
);
|
||||
}),
|
||||
],
|
||||
if (_errorMessage != null) ...[
|
||||
const SizedBox(height: 16),
|
||||
Card(
|
||||
color: Theme.of(context).colorScheme.errorContainer,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.error_outline, color: Theme.of(context).colorScheme.onErrorContainer),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
_errorMessage!,
|
||||
style: TextStyle(color: Theme.of(context).colorScheme.onErrorContainer),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
String _formatDate(DateTime date) {
|
||||
final now = DateTime.now();
|
||||
final difference = now.difference(date);
|
||||
|
||||
if (difference.inMinutes < 1) {
|
||||
return 'Just now';
|
||||
} else if (difference.inHours < 1) {
|
||||
return '${difference.inMinutes}m ago';
|
||||
} else if (difference.inDays < 1) {
|
||||
return '${difference.inHours}h ago';
|
||||
} else if (difference.inDays < 7) {
|
||||
return '${difference.inDays}d ago';
|
||||
} else {
|
||||
return '${date.month}/${date.day}/${date.year}';
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _showRemoveSessionDialog(RecentRemoteSession session) async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Remove Recent Connection'),
|
||||
content: Text('Remove "${session.deviceName}" from recent connections?'),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('Cancel')),
|
||||
TextButton(onPressed: () => Navigator.pop(context, true), child: const Text('Remove')),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (confirmed == true && mounted) {
|
||||
await context.read<CompanionRemoteProvider>().removeRecentSession(session.sessionId);
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildManualEntryTab() {
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(24.0),
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
const Icon(Icons.keyboard, size: 64, color: Colors.blue),
|
||||
const SizedBox(height: 24),
|
||||
Text('Pair with Desktop', style: Theme.of(context).textTheme.headlineMedium, textAlign: TextAlign.center),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Enter the session details shown on your desktop device',
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
if (_errorMessage != null) ...[
|
||||
Card(
|
||||
color: Theme.of(context).colorScheme.errorContainer,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.error_outline, color: Theme.of(context).colorScheme.onErrorContainer),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
_errorMessage!,
|
||||
style: TextStyle(color: Theme.of(context).colorScheme.onErrorContainer),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
TextFormField(
|
||||
controller: _hostAddressController,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Host Address',
|
||||
hintText: '192.168.1.100:48632',
|
||||
border: const OutlineInputBorder(),
|
||||
prefixIcon: const Icon(Icons.computer),
|
||||
suffixIcon: IconButton(
|
||||
icon: const Icon(Icons.paste),
|
||||
onPressed: () => _pasteFromClipboard(_hostAddressController),
|
||||
tooltip: 'Paste',
|
||||
),
|
||||
),
|
||||
validator: (value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return 'Please enter host address';
|
||||
}
|
||||
// Validate IP:port format
|
||||
final parts = value.split(':');
|
||||
if (parts.length != 2) {
|
||||
return 'Format must be IP:port (e.g., 192.168.1.100:48632)';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
enabled: !_isConnecting,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextFormField(
|
||||
controller: _sessionIdController,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Session ID',
|
||||
hintText: 'Enter 8-character session ID',
|
||||
border: const OutlineInputBorder(),
|
||||
prefixIcon: const Icon(Icons.vpn_key),
|
||||
suffixIcon: IconButton(
|
||||
icon: const Icon(Icons.paste),
|
||||
onPressed: () => _pasteFromClipboard(_sessionIdController),
|
||||
tooltip: 'Paste',
|
||||
),
|
||||
),
|
||||
textCapitalization: TextCapitalization.characters,
|
||||
inputFormatters: [
|
||||
FilteringTextInputFormatter.allow(RegExp(r'[A-Za-z0-9]')),
|
||||
LengthLimitingTextInputFormatter(8),
|
||||
TextInputFormatter.withFunction((oldValue, newValue) {
|
||||
return newValue.copyWith(text: newValue.text.toUpperCase());
|
||||
}),
|
||||
],
|
||||
validator: (value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return 'Please enter a session ID';
|
||||
}
|
||||
if (value.length != 8) {
|
||||
return 'Session ID must be 8 characters';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
enabled: !_isConnecting,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextFormField(
|
||||
controller: _pinController,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'PIN',
|
||||
hintText: 'Enter 6-digit PIN',
|
||||
border: const OutlineInputBorder(),
|
||||
prefixIcon: const Icon(Icons.lock),
|
||||
suffixIcon: IconButton(
|
||||
icon: const Icon(Icons.paste),
|
||||
onPressed: () => _pasteFromClipboard(_pinController),
|
||||
tooltip: 'Paste',
|
||||
),
|
||||
),
|
||||
keyboardType: TextInputType.number,
|
||||
inputFormatters: [FilteringTextInputFormatter.digitsOnly, LengthLimitingTextInputFormatter(6)],
|
||||
validator: (value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return 'Please enter a PIN';
|
||||
}
|
||||
if (value.length != 6) {
|
||||
return 'PIN must be 6 digits';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
enabled: !_isConnecting,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
FilledButton.icon(
|
||||
onPressed: _isConnecting ? null : _connect,
|
||||
icon: _isConnecting
|
||||
? const SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2))
|
||||
: const Icon(Icons.link),
|
||||
label: Text(_isConnecting ? 'Connecting...' : 'Connect'),
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
const Divider(),
|
||||
const SizedBox(height: 16),
|
||||
Text('Tips', style: Theme.of(context).textTheme.titleMedium),
|
||||
const SizedBox(height: 8),
|
||||
_buildTipCard(
|
||||
context,
|
||||
Icons.computer,
|
||||
'Open Plezy on your desktop and enable Companion Remote from settings or menu',
|
||||
),
|
||||
if (_isMobile) ...[
|
||||
const SizedBox(height: 8),
|
||||
_buildTipCard(
|
||||
context,
|
||||
Icons.qr_code,
|
||||
'Use the Scan tab to quickly pair by scanning the QR code on your desktop',
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 8),
|
||||
_buildTipCard(context, Icons.wifi, 'Make sure both devices are on the same WiFi network'),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTipCard(BuildContext context, IconData icon, String text) {
|
||||
return Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(12.0),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(icon, size: 20, color: Theme.of(context).colorScheme.primary),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(child: Text(text, style: Theme.of(context).textTheme.bodySmall)),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -39,6 +39,9 @@ import 'auth_screen.dart';
|
||||
import 'libraries/state_messages.dart';
|
||||
import 'main_screen.dart';
|
||||
import '../watch_together/watch_together.dart';
|
||||
import '../providers/companion_remote_provider.dart';
|
||||
import '../widgets/companion_remote/remote_session_dialog.dart';
|
||||
import 'companion_remote/mobile_remote_screen.dart';
|
||||
|
||||
class DiscoverScreen extends StatefulWidget {
|
||||
final VoidCallback? onBecameVisible;
|
||||
@@ -118,6 +121,9 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
||||
_refreshContinueWatching();
|
||||
}
|
||||
|
||||
// Track initial load so we can focus hero when content first appears
|
||||
bool _initialLoadComplete = false;
|
||||
|
||||
// Hub navigation keys
|
||||
GlobalKey<HubSectionState>? _continueWatchingHubKey;
|
||||
final List<GlobalKey<HubSectionState>> _hubKeys = [];
|
||||
@@ -126,9 +132,11 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
||||
late FocusNode _heroFocusNode;
|
||||
late FocusNode _refreshButtonFocusNode;
|
||||
late FocusNode _watchTogetherButtonFocusNode;
|
||||
late FocusNode _companionRemoteButtonFocusNode;
|
||||
late FocusNode _userButtonFocusNode;
|
||||
bool _isRefreshFocused = false;
|
||||
bool _isWatchTogetherFocused = false;
|
||||
bool _isCompanionRemoteFocused = false;
|
||||
bool _isUserFocused = false;
|
||||
|
||||
/// Get the correct PlexClient for an item's server
|
||||
@@ -242,9 +250,11 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
||||
_heroFocusNode = FocusNode(debugLabel: 'hero_section');
|
||||
_refreshButtonFocusNode = FocusNode(debugLabel: 'refresh_button');
|
||||
_watchTogetherButtonFocusNode = FocusNode(debugLabel: 'watch_together_button');
|
||||
_companionRemoteButtonFocusNode = FocusNode(debugLabel: 'companion_remote_button');
|
||||
_userButtonFocusNode = FocusNode(debugLabel: 'user_button');
|
||||
_refreshButtonFocusNode.addListener(_onRefreshFocusChange);
|
||||
_watchTogetherButtonFocusNode.addListener(_onWatchTogetherFocusChange);
|
||||
_companionRemoteButtonFocusNode.addListener(_onCompanionRemoteFocusChange);
|
||||
_userButtonFocusNode.addListener(_onUserFocusChange);
|
||||
_loadContent();
|
||||
_startAutoScroll();
|
||||
@@ -266,6 +276,14 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
||||
}
|
||||
}
|
||||
|
||||
void _onCompanionRemoteFocusChange() {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isCompanionRemoteFocused = _companionRemoteButtonFocusNode.hasFocus;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void _onUserFocusChange() {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
@@ -386,7 +404,13 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
|
||||
// RIGHT: Move to user button
|
||||
// RIGHT: Move to companion remote button (desktop only)
|
||||
if (key.isRightKey && PlatformDetector.isDesktop(context)) {
|
||||
_companionRemoteButtonFocusNode.requestFocus();
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
|
||||
// RIGHT: Move to user button (non-desktop, skip companion remote)
|
||||
if (key.isRightKey) {
|
||||
_userButtonFocusNode.requestFocus();
|
||||
return KeyEventResult.handled;
|
||||
@@ -406,6 +430,46 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
||||
return KeyEventResult.ignored;
|
||||
}
|
||||
|
||||
/// Handle key events for the companion remote button in app bar
|
||||
KeyEventResult _handleCompanionRemoteKeyEvent(FocusNode node, KeyEvent event) {
|
||||
if (!event.isActionable) {
|
||||
return KeyEventResult.ignored;
|
||||
}
|
||||
|
||||
final key = event.logicalKey;
|
||||
|
||||
// DOWN: Return to hero
|
||||
if (key.isDownKey) {
|
||||
_heroFocusNode.requestFocus();
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
|
||||
// LEFT: Move to watch together button
|
||||
if (key.isLeftKey) {
|
||||
_watchTogetherButtonFocusNode.requestFocus();
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
|
||||
// RIGHT: Move to user button
|
||||
if (key.isRightKey) {
|
||||
_userButtonFocusNode.requestFocus();
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
|
||||
// UP: Block at boundary
|
||||
if (key.isUpKey) {
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
|
||||
// SELECT: Show companion remote dialog
|
||||
if (key.isSelectKey) {
|
||||
RemoteSessionDialog.show(context);
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
|
||||
return KeyEventResult.ignored;
|
||||
}
|
||||
|
||||
/// Handle key events for the user button in app bar
|
||||
KeyEventResult _handleUserKeyEvent(FocusNode node, KeyEvent event) {
|
||||
if (!event.isActionable) {
|
||||
@@ -420,7 +484,13 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
|
||||
// LEFT: Move to watch together button
|
||||
// LEFT: Move to companion remote button (desktop only)
|
||||
if (key.isLeftKey && PlatformDetector.isDesktop(context)) {
|
||||
_companionRemoteButtonFocusNode.requestFocus();
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
|
||||
// LEFT: Move to watch together button (non-desktop, skip companion remote)
|
||||
if (key.isLeftKey) {
|
||||
_watchTogetherButtonFocusNode.requestFocus();
|
||||
return KeyEventResult.handled;
|
||||
@@ -454,6 +524,8 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
||||
_refreshButtonFocusNode.dispose();
|
||||
_watchTogetherButtonFocusNode.removeListener(_onWatchTogetherFocusChange);
|
||||
_watchTogetherButtonFocusNode.dispose();
|
||||
_companionRemoteButtonFocusNode.removeListener(_onCompanionRemoteFocusChange);
|
||||
_companionRemoteButtonFocusNode.dispose();
|
||||
_userButtonFocusNode.removeListener(_onUserFocusChange);
|
||||
_userButtonFocusNode.dispose();
|
||||
super.dispose();
|
||||
@@ -645,6 +717,16 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
||||
_heroController.jumpToPage(0);
|
||||
}
|
||||
|
||||
// On initial load, focus the hero so the user starts on content (not the toolbar)
|
||||
if (!_initialLoadComplete && onDeck.isNotEmpty) {
|
||||
_initialLoadComplete = true;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted && _heroFocusNode.canRequestFocus) {
|
||||
_heroFocusNode.requestFocus();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Wait for global hubs
|
||||
final allHubs = await hubsFuture;
|
||||
|
||||
@@ -1037,6 +1119,59 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
||||
);
|
||||
},
|
||||
),
|
||||
// Companion Remote button
|
||||
Consumer<CompanionRemoteProvider>(
|
||||
builder: (context, companionRemote, child) {
|
||||
final isDesktop = PlatformDetector.isDesktop(context);
|
||||
|
||||
return Focus(
|
||||
focusNode: isDesktop ? _companionRemoteButtonFocusNode : null,
|
||||
onKeyEvent: isDesktop ? _handleCompanionRemoteKeyEvent : null,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: isDesktop && _isCompanionRemoteFocused
|
||||
? Colors.white.withValues(alpha: 0.2)
|
||||
: Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Stack(
|
||||
children: [
|
||||
IconButton(
|
||||
icon: AppIcon(
|
||||
Symbols.phone_android_rounded,
|
||||
fill: companionRemote.isConnected ? 1 : 0,
|
||||
color: companionRemote.isConnected ? Theme.of(context).colorScheme.primary : Colors.white,
|
||||
),
|
||||
onPressed: () {
|
||||
if (isDesktop) {
|
||||
RemoteSessionDialog.show(context);
|
||||
} else {
|
||||
Navigator.push(context, MaterialPageRoute(builder: (context) => MobileRemoteScreen()));
|
||||
}
|
||||
},
|
||||
tooltip: 'Companion Remote',
|
||||
),
|
||||
// Badge showing connection status
|
||||
if (companionRemote.isConnected)
|
||||
Positioned(
|
||||
top: 6,
|
||||
right: 6,
|
||||
child: Container(
|
||||
width: 8,
|
||||
height: 8,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.green,
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(color: Colors.white, width: 1),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
Consumer<UserProfileProvider>(
|
||||
builder: (context, userProvider, child) {
|
||||
return Focus(
|
||||
@@ -1233,8 +1368,10 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
||||
],
|
||||
),
|
||||
),
|
||||
// Overlaid app bar
|
||||
Positioned(top: 0, left: 0, right: 0, child: _buildOverlaidAppBar()),
|
||||
// Overlaid app bar — excluded from default focus traversal so that
|
||||
// initial/tab-switch focus lands on content (hero/hubs), not the toolbar.
|
||||
// Toolbar buttons are still reachable via explicit UP from hero section.
|
||||
Positioned(top: 0, left: 0, right: 0, child: ExcludeFocusTraversal(child: _buildOverlaidAppBar())),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
@@ -28,6 +28,8 @@ import '../services/settings_service.dart';
|
||||
import '../providers/offline_mode_provider.dart';
|
||||
import '../services/plex_auth_service.dart';
|
||||
import '../services/storage_service.dart';
|
||||
import '../services/companion_remote/companion_remote_receiver.dart';
|
||||
import '../providers/companion_remote_provider.dart';
|
||||
import '../utils/desktop_window_padding.dart';
|
||||
import '../widgets/side_navigation_rail.dart';
|
||||
import '../focus/key_event_utils.dart';
|
||||
@@ -362,6 +364,8 @@ class _MainScreenState extends State<MainScreen> with RouteAware, WindowListener
|
||||
}
|
||||
}
|
||||
|
||||
bool _companionRemoteSetup = false;
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
@@ -378,9 +382,71 @@ class _MainScreenState extends State<MainScreen> with RouteAware, WindowListener
|
||||
_offlineModeProvider!.addListener(_handleOfflineStatusChanged);
|
||||
}
|
||||
|
||||
// Wire up Companion Remote command routing (desktop only, once)
|
||||
if (!_companionRemoteSetup && PlatformDetector.isDesktop(context)) {
|
||||
_companionRemoteSetup = true;
|
||||
_setupCompanionRemote();
|
||||
}
|
||||
|
||||
routeObserver.subscribe(this, ModalRoute.of(context) as PageRoute);
|
||||
}
|
||||
|
||||
void _setupCompanionRemote() {
|
||||
final companionRemote = context.read<CompanionRemoteProvider>();
|
||||
companionRemote.onCommandReceived = (command) {
|
||||
if (mounted) {
|
||||
CompanionRemoteReceiver.instance.handleCommand(command, context);
|
||||
}
|
||||
};
|
||||
|
||||
final receiver = CompanionRemoteReceiver.instance;
|
||||
final tabCount = _getVisibleTabs(_isOffline).length;
|
||||
|
||||
receiver.onTabNext = () {
|
||||
_selectTab((_currentIndex + 1) % tabCount);
|
||||
};
|
||||
receiver.onTabPrevious = () {
|
||||
_selectTab((_currentIndex - 1 + tabCount) % tabCount);
|
||||
};
|
||||
receiver.onTabDiscover = () {
|
||||
final idx = NavigationTab.indexFor(NavigationTabId.discover, isOffline: _isOffline);
|
||||
if (idx >= 0) _selectTab(idx);
|
||||
};
|
||||
receiver.onTabLibraries = () {
|
||||
final idx = NavigationTab.indexFor(NavigationTabId.libraries, isOffline: _isOffline);
|
||||
if (idx >= 0) _selectTab(idx);
|
||||
};
|
||||
receiver.onTabSearch = () {
|
||||
final idx = NavigationTab.indexFor(NavigationTabId.search, isOffline: _isOffline);
|
||||
if (idx >= 0) _selectTab(idx);
|
||||
};
|
||||
receiver.onTabDownloads = () {
|
||||
final idx = NavigationTab.indexFor(NavigationTabId.downloads, isOffline: _isOffline);
|
||||
if (idx >= 0) _selectTab(idx);
|
||||
};
|
||||
receiver.onTabSettings = () {
|
||||
final idx = NavigationTab.indexFor(NavigationTabId.settings, isOffline: _isOffline);
|
||||
if (idx >= 0) _selectTab(idx);
|
||||
};
|
||||
receiver.onHome = () {
|
||||
final idx = NavigationTab.indexFor(NavigationTabId.discover, isOffline: _isOffline);
|
||||
if (idx >= 0) _selectTab(idx);
|
||||
};
|
||||
receiver.onSearchAction = (query) {
|
||||
final idx = NavigationTab.indexFor(NavigationTabId.search, isOffline: _isOffline);
|
||||
if (idx >= 0) {
|
||||
_selectTab(idx);
|
||||
if (query != null && query.isNotEmpty) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (_searchKey.currentState case final SearchInputFocusable searchable) {
|
||||
searchable.setSearchQuery(query);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
@@ -392,6 +458,21 @@ class _MainScreenState extends State<MainScreen> with RouteAware, WindowListener
|
||||
_offlineModeProvider?.removeListener(_handleOfflineStatusChanged);
|
||||
_sidebarFocusScope.dispose();
|
||||
_contentFocusScope.dispose();
|
||||
|
||||
// Clean up companion remote callbacks
|
||||
if (_companionRemoteSetup) {
|
||||
final receiver = CompanionRemoteReceiver.instance;
|
||||
receiver.onTabNext = null;
|
||||
receiver.onTabPrevious = null;
|
||||
receiver.onTabDiscover = null;
|
||||
receiver.onTabLibraries = null;
|
||||
receiver.onTabSearch = null;
|
||||
receiver.onTabDownloads = null;
|
||||
receiver.onTabSettings = null;
|
||||
receiver.onHome = null;
|
||||
receiver.onSearchAction = null;
|
||||
}
|
||||
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
|
||||
@@ -133,6 +133,12 @@ class _SearchScreenState extends State<SearchScreen> with Refreshable, FullRefre
|
||||
FocusUtils.requestFocusAfterBuild(this, _searchFocusNode);
|
||||
}
|
||||
|
||||
/// Set the search query externally (e.g. from companion remote)
|
||||
@override
|
||||
void setSearchQuery(String query) {
|
||||
_searchController.text = query;
|
||||
}
|
||||
|
||||
// Public method to fully reload all content (for profile switches)
|
||||
@override
|
||||
void fullRefresh() {
|
||||
|
||||
@@ -27,6 +27,9 @@ import '../../utils/platform_detector.dart';
|
||||
import '../../widgets/desktop_app_bar.dart';
|
||||
import '../../widgets/tv_number_spinner.dart';
|
||||
import 'hotkey_recorder_widget.dart';
|
||||
import '../../providers/companion_remote_provider.dart';
|
||||
import '../../screens/companion_remote/mobile_remote_screen.dart';
|
||||
import '../../widgets/companion_remote/remote_session_dialog.dart';
|
||||
import 'about_screen.dart';
|
||||
import 'logs_screen.dart';
|
||||
import 'mpv_config_screen.dart';
|
||||
@@ -206,6 +209,8 @@ class _SettingsScreenState extends State<SettingsScreen> with FocusableTab {
|
||||
_buildDownloadsSection(),
|
||||
const SizedBox(height: 24),
|
||||
if (_keyboardShortcutsSupported) ...[_buildKeyboardShortcutsSection(), const SizedBox(height: 24)],
|
||||
_buildCompanionRemoteSection(),
|
||||
const SizedBox(height: 24),
|
||||
_buildAdvancedSection(),
|
||||
const SizedBox(height: 24),
|
||||
if (UpdateService.isUpdateCheckEnabled) ...[_buildUpdateSection(), const SizedBox(height: 24)],
|
||||
@@ -776,6 +781,51 @@ class _SettingsScreenState extends State<SettingsScreen> with FocusableTab {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCompanionRemoteSection() {
|
||||
return Consumer<CompanionRemoteProvider>(
|
||||
builder: (context, companionRemote, child) {
|
||||
return Card(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Text(
|
||||
'Companion Remote',
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
if (PlatformDetector.isDesktop(context))
|
||||
ListTile(
|
||||
leading: const AppIcon(Symbols.phone_android_rounded, fill: 1),
|
||||
title: const Text('Host Remote Session'),
|
||||
subtitle: companionRemote.isConnected
|
||||
? Text('Connected to ${companionRemote.connectedDevice?.name}')
|
||||
: const Text('Control this device with your phone'),
|
||||
trailing: companionRemote.isConnected
|
||||
? const AppIcon(Symbols.check_circle_rounded, fill: 1, color: Colors.green)
|
||||
: const AppIcon(Symbols.chevron_right_rounded, fill: 1),
|
||||
onTap: () => RemoteSessionDialog.show(context),
|
||||
)
|
||||
else
|
||||
ListTile(
|
||||
leading: const AppIcon(Symbols.phone_android_rounded, fill: 1),
|
||||
title: const Text('Remote Control'),
|
||||
subtitle: companionRemote.isConnected
|
||||
? Text('Connected to ${companionRemote.connectedDevice?.name}')
|
||||
: const Text('Control a desktop device'),
|
||||
trailing: const AppIcon(Symbols.chevron_right_rounded, fill: 1),
|
||||
onTap: () {
|
||||
Navigator.push(context, MaterialPageRoute(builder: (context) => const MobileRemoteScreen()));
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAdvancedSection() {
|
||||
return Card(
|
||||
child: Column(
|
||||
|
||||
@@ -22,6 +22,10 @@ import '../models/plex_media_info.dart';
|
||||
import '../providers/download_provider.dart';
|
||||
import '../providers/multi_server_provider.dart';
|
||||
import '../providers/playback_state_provider.dart';
|
||||
import '../models/companion_remote/remote_command_type.dart';
|
||||
import '../providers/companion_remote_provider.dart';
|
||||
import '../services/companion_remote/companion_remote_receiver.dart';
|
||||
import '../services/macos_window_service.dart';
|
||||
import '../services/discord_rpc_service.dart';
|
||||
import '../services/episode_navigation_service.dart';
|
||||
import '../services/media_controls_manager.dart';
|
||||
@@ -38,11 +42,13 @@ import '../providers/shader_provider.dart';
|
||||
import '../providers/user_profile_provider.dart';
|
||||
import '../utils/app_logger.dart';
|
||||
import '../utils/dialogs.dart';
|
||||
import '../utils/player_utils.dart';
|
||||
import '../utils/orientation_helper.dart';
|
||||
import '../utils/platform_detector.dart';
|
||||
import '../utils/provider_extensions.dart';
|
||||
import '../utils/language_codes.dart';
|
||||
import '../utils/snackbar_helper.dart';
|
||||
import '../utils/track_label_builder.dart' as tlb;
|
||||
import '../utils/plex_url_helper.dart';
|
||||
import '../utils/video_player_navigation.dart';
|
||||
import '../widgets/video_controls/video_controls.dart';
|
||||
@@ -132,6 +138,10 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
||||
// Watch Together provider reference (stored early to use in dispose)
|
||||
WatchTogetherProvider? _watchTogetherProvider;
|
||||
|
||||
// Companion remote state (stored early for use in dispose)
|
||||
CompanionRemoteProvider? _companionRemoteProvider;
|
||||
VoidCallback? _savedOnHome;
|
||||
|
||||
/// Get the correct PlexClient for this metadata's server
|
||||
PlexClient _getClientForMetadata(BuildContext context) {
|
||||
return context.getClientForServer(widget.metadata.serverId!);
|
||||
@@ -205,6 +215,9 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
||||
// Register app lifecycle observer
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
|
||||
// Wire companion remote playback callbacks
|
||||
_setupCompanionRemoteCallbacks();
|
||||
|
||||
// Initialize player asynchronously with buffer size from settings
|
||||
_initializePlayer();
|
||||
}
|
||||
@@ -1127,6 +1140,160 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
||||
navigateToVideoPlayer(context, metadata: metadata, usePushReplacement: true);
|
||||
}
|
||||
|
||||
void _setupCompanionRemoteCallbacks() {
|
||||
final receiver = CompanionRemoteReceiver.instance;
|
||||
receiver.onStop = () {
|
||||
if (mounted) _handleBackButton();
|
||||
};
|
||||
receiver.onNextTrack = () {
|
||||
if (mounted && _nextEpisode != null) _playNext();
|
||||
};
|
||||
receiver.onPreviousTrack = () {
|
||||
if (mounted && _previousEpisode != null) _playPrevious();
|
||||
};
|
||||
receiver.onSeekForward = () async {
|
||||
if (player == null) return;
|
||||
final settings = await SettingsService.getInstance();
|
||||
seekWithClamping(player!, Duration(seconds: settings.getSeekTimeSmall()));
|
||||
};
|
||||
receiver.onSeekBackward = () async {
|
||||
if (player == null) return;
|
||||
final settings = await SettingsService.getInstance();
|
||||
seekWithClamping(player!, Duration(seconds: -settings.getSeekTimeSmall()));
|
||||
};
|
||||
receiver.onVolumeUp = () async {
|
||||
if (player == null) return;
|
||||
final settings = await SettingsService.getInstance();
|
||||
final maxVol = settings.getMaxVolume().toDouble();
|
||||
final newVolume = (player!.state.volume + 10).clamp(0.0, maxVol);
|
||||
player!.setVolume(newVolume);
|
||||
settings.setVolume(newVolume);
|
||||
};
|
||||
receiver.onVolumeDown = () async {
|
||||
if (player == null) return;
|
||||
final settings = await SettingsService.getInstance();
|
||||
final maxVol = settings.getMaxVolume().toDouble();
|
||||
final newVolume = (player!.state.volume - 10).clamp(0.0, maxVol);
|
||||
player!.setVolume(newVolume);
|
||||
settings.setVolume(newVolume);
|
||||
};
|
||||
receiver.onVolumeMute = () async {
|
||||
if (player == null) return;
|
||||
final settings = await SettingsService.getInstance();
|
||||
final newVolume = player!.state.volume > 0 ? 0.0 : 100.0;
|
||||
player!.setVolume(newVolume);
|
||||
settings.setVolume(newVolume);
|
||||
};
|
||||
receiver.onSubtitles = _cycleSubtitleTrack;
|
||||
receiver.onAudioTracks = _cycleAudioTrack;
|
||||
receiver.onFullscreen = _toggleFullscreen;
|
||||
|
||||
// Override home to exit the player first (main screen handler runs after pop)
|
||||
_savedOnHome = receiver.onHome;
|
||||
receiver.onHome = () {
|
||||
if (mounted) _handleBackButton();
|
||||
};
|
||||
|
||||
// Store provider reference for use in dispose and notify remote
|
||||
try {
|
||||
_companionRemoteProvider = context.read<CompanionRemoteProvider>();
|
||||
_companionRemoteProvider!.sendCommand(
|
||||
RemoteCommandType.syncState,
|
||||
data: {'playerActive': true},
|
||||
);
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
void _cleanupCompanionRemoteCallbacks() {
|
||||
final receiver = CompanionRemoteReceiver.instance;
|
||||
receiver.onStop = null;
|
||||
receiver.onNextTrack = null;
|
||||
receiver.onPreviousTrack = null;
|
||||
receiver.onSeekForward = null;
|
||||
receiver.onSeekBackward = null;
|
||||
receiver.onVolumeUp = null;
|
||||
receiver.onVolumeDown = null;
|
||||
receiver.onVolumeMute = null;
|
||||
receiver.onSubtitles = null;
|
||||
receiver.onAudioTracks = null;
|
||||
receiver.onFullscreen = null;
|
||||
receiver.onHome = _savedOnHome;
|
||||
_savedOnHome = null;
|
||||
|
||||
// Notify remote that player is no longer active
|
||||
_companionRemoteProvider?.sendCommand(
|
||||
RemoteCommandType.syncState,
|
||||
data: {'playerActive': false},
|
||||
);
|
||||
_companionRemoteProvider = null;
|
||||
}
|
||||
|
||||
void _cycleSubtitleTrack() {
|
||||
if (player == null) return;
|
||||
final tracks = player!.state.tracks.subtitle.where((t) => t.id != 'auto').toList();
|
||||
if (tracks.isEmpty) return;
|
||||
|
||||
final current = player!.state.track.subtitle;
|
||||
// tracks includes 'no' (off). Find current index and advance.
|
||||
final currentIndex = tracks.indexWhere((t) => t.id == current?.id);
|
||||
final nextIndex = (currentIndex + 1) % tracks.length;
|
||||
final next = tracks[nextIndex];
|
||||
player!.selectSubtitleTrack(next);
|
||||
_onSubtitleTrackChanged(next);
|
||||
|
||||
if (mounted) {
|
||||
final label = next.id == 'no'
|
||||
? 'Subtitles: Off'
|
||||
: 'Subtitles: ${tlb.TrackLabelBuilder.buildSubtitleLabel(title: next.title, language: next.language, codec: next.codec, index: nextIndex)}';
|
||||
showAppSnackBar(context, label, duration: const Duration(seconds: 1));
|
||||
}
|
||||
}
|
||||
|
||||
void _cycleAudioTrack() {
|
||||
if (player == null) return;
|
||||
final tracks = player!.state.tracks.audio.where((t) => t.id != 'auto' && t.id != 'no').toList();
|
||||
if (tracks.length <= 1) return;
|
||||
|
||||
final current = player!.state.track.audio;
|
||||
final currentIndex = tracks.indexWhere((t) => t.id == current?.id);
|
||||
final nextIndex = (currentIndex + 1) % tracks.length;
|
||||
final next = tracks[nextIndex];
|
||||
player!.selectAudioTrack(next);
|
||||
_onAudioTrackChanged(next);
|
||||
|
||||
if (mounted) {
|
||||
final label = 'Audio: ${tlb.TrackLabelBuilder.buildAudioLabel(title: next.title, language: next.language, codec: next.codec, channelsCount: next.channelsCount, index: nextIndex)}';
|
||||
showAppSnackBar(context, label, duration: const Duration(seconds: 1));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _toggleFullscreen() async {
|
||||
if (PlatformDetector.isMobile(context)) return;
|
||||
final isCurrentlyFullscreen = await windowManager.isFullScreen();
|
||||
if (Platform.isMacOS) {
|
||||
if (isCurrentlyFullscreen) {
|
||||
await MacOSWindowService.exitFullscreen();
|
||||
} else {
|
||||
await MacOSWindowService.enterFullscreen();
|
||||
}
|
||||
} else {
|
||||
await windowManager.setFullScreen(!isCurrentlyFullscreen);
|
||||
}
|
||||
}
|
||||
|
||||
/// Exit fullscreen before leaving the player (Windows/Linux only).
|
||||
/// macOS is excluded because we can't distinguish native fullscreen
|
||||
/// from maximized state, so we leave the window state unchanged.
|
||||
Future<void> _exitFullscreenIfNeeded() async {
|
||||
if (Platform.isWindows || Platform.isLinux) {
|
||||
final isFullscreen = await windowManager.isFullScreen();
|
||||
if (isFullscreen) {
|
||||
await windowManager.setFullScreen(false);
|
||||
await Future.delayed(const Duration(milliseconds: 100));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle back button press
|
||||
/// For non-host participants in Watch Together, shows leave session confirmation
|
||||
Future<void> _handleBackButton() async {
|
||||
@@ -1146,16 +1313,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
||||
if (confirmed && mounted) {
|
||||
await _watchTogetherProvider!.leaveSession();
|
||||
if (mounted) {
|
||||
// Exit fullscreen before leaving player (Windows/Linux only)
|
||||
if (Platform.isWindows || Platform.isLinux) {
|
||||
final isFullscreen = await windowManager.isFullScreen();
|
||||
if (isFullscreen) {
|
||||
await windowManager.setFullScreen(false);
|
||||
// Wait for a frame to allow window manager to process the fullscreen exit
|
||||
await Future.delayed(const Duration(milliseconds: 100));
|
||||
if (!mounted) return;
|
||||
}
|
||||
}
|
||||
await _exitFullscreenIfNeeded();
|
||||
if (!mounted) return;
|
||||
_isExiting.value = true;
|
||||
Navigator.of(context).pop(true);
|
||||
@@ -1164,16 +1322,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
||||
return;
|
||||
}
|
||||
|
||||
// Exit fullscreen before leaving player (Windows/Linux only)
|
||||
if (Platform.isWindows || Platform.isLinux) {
|
||||
final isFullscreen = await windowManager.isFullScreen();
|
||||
if (isFullscreen) {
|
||||
await windowManager.setFullScreen(false);
|
||||
// Wait for a frame to allow window manager to process the fullscreen exit
|
||||
await Future.delayed(const Duration(milliseconds: 100));
|
||||
if (!mounted) return;
|
||||
}
|
||||
}
|
||||
await _exitFullscreenIfNeeded();
|
||||
|
||||
// Default behavior for hosts or non-session users
|
||||
if (!mounted) return;
|
||||
@@ -1189,6 +1338,9 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
||||
// Unregister app lifecycle observer
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
|
||||
// Clean up companion remote playback callbacks
|
||||
_cleanupCompanionRemoteCallbacks();
|
||||
|
||||
// Notify Watch Together guests that host is exiting the player
|
||||
// Use stored reference since context.read() may fail in dispose
|
||||
// Skip if replacing with another video (episode navigation)
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import '../../models/companion_remote/recent_remote_session.dart';
|
||||
import '../../services/storage_service.dart';
|
||||
import '../../utils/app_logger.dart';
|
||||
|
||||
/// Service for managing recent Companion Remote sessions
|
||||
class CompanionRemoteDiscoveryService {
|
||||
static const String _storageKey = 'companion_remote_recent_sessions';
|
||||
static const int _maxRecentSessions = 5;
|
||||
|
||||
final _recentSessions = <RecentRemoteSession>[];
|
||||
final _recentSessionsController = StreamController<List<RecentRemoteSession>>.broadcast();
|
||||
|
||||
/// Stream of recent sessions
|
||||
Stream<List<RecentRemoteSession>> get recentSessions => _recentSessionsController.stream;
|
||||
|
||||
/// Get current list of recent sessions
|
||||
List<RecentRemoteSession> get currentSessions => List.unmodifiable(_recentSessions);
|
||||
|
||||
CompanionRemoteDiscoveryService() {
|
||||
_loadRecentSessions();
|
||||
}
|
||||
|
||||
/// Load recent sessions from storage
|
||||
Future<void> _loadRecentSessions() async {
|
||||
try {
|
||||
final storage = await StorageService.getInstance();
|
||||
final json = storage.prefs.getString(_storageKey);
|
||||
|
||||
if (json != null) {
|
||||
final List<dynamic> list = jsonDecode(json);
|
||||
_recentSessions.clear();
|
||||
_recentSessions.addAll(list.map((e) => RecentRemoteSession.fromJson(e as Map<String, dynamic>)));
|
||||
|
||||
// Sort by last connected (most recent first)
|
||||
_recentSessions.sort((a, b) => b.lastConnected.compareTo(a.lastConnected));
|
||||
|
||||
_recentSessionsController.add(currentSessions);
|
||||
appLogger.d('Loaded ${_recentSessions.length} recent remote sessions');
|
||||
}
|
||||
} catch (e) {
|
||||
appLogger.e('Failed to load recent sessions', error: e);
|
||||
}
|
||||
}
|
||||
|
||||
/// Save recent sessions to storage
|
||||
Future<void> _saveRecentSessions() async {
|
||||
try {
|
||||
final storage = await StorageService.getInstance();
|
||||
final json = jsonEncode(_recentSessions.map((e) => e.toJson()).toList());
|
||||
await storage.prefs.setString(_storageKey, json);
|
||||
appLogger.d('Saved ${_recentSessions.length} recent remote sessions');
|
||||
} catch (e) {
|
||||
appLogger.e('Failed to save recent sessions', error: e);
|
||||
}
|
||||
}
|
||||
|
||||
/// Add a session to recent list
|
||||
Future<void> addRecentSession(RecentRemoteSession session) async {
|
||||
// Remove existing entry for this session ID
|
||||
_recentSessions.removeWhere((s) => s.sessionId == session.sessionId);
|
||||
|
||||
// Add new entry at the beginning
|
||||
_recentSessions.insert(0, session);
|
||||
|
||||
// Limit to max sessions
|
||||
if (_recentSessions.length > _maxRecentSessions) {
|
||||
_recentSessions.removeRange(_maxRecentSessions, _recentSessions.length);
|
||||
}
|
||||
|
||||
await _saveRecentSessions();
|
||||
_recentSessionsController.add(currentSessions);
|
||||
}
|
||||
|
||||
/// Remove a session from recent list
|
||||
Future<void> removeRecentSession(String sessionId) async {
|
||||
_recentSessions.removeWhere((s) => s.sessionId == sessionId);
|
||||
await _saveRecentSessions();
|
||||
_recentSessionsController.add(currentSessions);
|
||||
}
|
||||
|
||||
/// Clear all recent sessions
|
||||
Future<void> clearRecentSessions() async {
|
||||
_recentSessions.clear();
|
||||
await _saveRecentSessions();
|
||||
_recentSessionsController.add(currentSessions);
|
||||
}
|
||||
|
||||
/// Dispose resources
|
||||
Future<void> dispose() async {
|
||||
await _recentSessionsController.close();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,540 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:web_socket_channel/io.dart';
|
||||
|
||||
import '../../models/companion_remote/remote_command.dart';
|
||||
import '../../models/companion_remote/remote_command_type.dart';
|
||||
import '../../models/companion_remote/remote_session.dart';
|
||||
import '../../utils/app_logger.dart';
|
||||
|
||||
enum RemotePeerErrorType {
|
||||
connectionFailed,
|
||||
peerDisconnected,
|
||||
dataChannelError,
|
||||
serverError,
|
||||
timeout,
|
||||
invalidSession,
|
||||
authFailed,
|
||||
networkError,
|
||||
unknown,
|
||||
}
|
||||
|
||||
class RemotePeerError {
|
||||
final RemotePeerErrorType type;
|
||||
final String message;
|
||||
final dynamic originalError;
|
||||
|
||||
const RemotePeerError({required this.type, required this.message, this.originalError});
|
||||
|
||||
@override
|
||||
String toString() => 'RemotePeerError($type): $message';
|
||||
}
|
||||
|
||||
class CompanionRemotePeerService {
|
||||
// Server-side (host) fields
|
||||
HttpServer? _server;
|
||||
WebSocket? _clientSocket;
|
||||
|
||||
// Client-side (remote) fields
|
||||
IOWebSocketChannel? _channel;
|
||||
|
||||
String? _sessionId;
|
||||
String? _pin;
|
||||
String? _myPeerId;
|
||||
String? _hostAddress; // Format: "ip:port"
|
||||
RemoteSessionRole? _role;
|
||||
|
||||
final _commandReceivedController = StreamController<RemoteCommand>.broadcast();
|
||||
final _deviceConnectedController = StreamController<RemoteDevice>.broadcast();
|
||||
final _deviceDisconnectedController = StreamController<void>.broadcast();
|
||||
final _errorController = StreamController<RemotePeerError>.broadcast();
|
||||
final _connectionStateController = StreamController<RemoteSessionStatus>.broadcast();
|
||||
|
||||
Timer? _pingTimer;
|
||||
|
||||
// Auth rate limiting
|
||||
int _failedAuthAttempts = 0;
|
||||
DateTime? _authLockoutUntil;
|
||||
static const int _maxFailedAuthAttempts = 5;
|
||||
static const Duration _authLockoutDuration = Duration(seconds: 30);
|
||||
|
||||
Stream<RemoteCommand> get onCommandReceived => _commandReceivedController.stream;
|
||||
Stream<RemoteDevice> get onDeviceConnected => _deviceConnectedController.stream;
|
||||
Stream<void> get onDeviceDisconnected => _deviceDisconnectedController.stream;
|
||||
Stream<RemotePeerError> get onError => _errorController.stream;
|
||||
Stream<RemoteSessionStatus> get onConnectionStateChanged => _connectionStateController.stream;
|
||||
|
||||
String? get sessionId => _sessionId;
|
||||
String? get pin => _pin;
|
||||
String? get myPeerId => _myPeerId;
|
||||
String? get hostAddress => _hostAddress;
|
||||
RemoteSessionRole? get role => _role;
|
||||
bool get isHost => _role == RemoteSessionRole.host;
|
||||
bool get isConnected => _clientSocket != null || (_channel != null && _channel?.closeCode == null);
|
||||
|
||||
String _generateSessionId() {
|
||||
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
|
||||
final random = Random.secure();
|
||||
return List.generate(8, (index) => chars[random.nextInt(chars.length)]).join();
|
||||
}
|
||||
|
||||
String _generatePin() {
|
||||
final random = Random.secure();
|
||||
return List.generate(6, (index) => random.nextInt(10).toString()).join();
|
||||
}
|
||||
|
||||
Future<String> _getLocalIpAddress() async {
|
||||
try {
|
||||
final interfaces = await NetworkInterface.list(type: InternetAddressType.IPv4);
|
||||
|
||||
// Prefer WiFi interface, then any non-loopback
|
||||
for (final interface in interfaces) {
|
||||
// Skip loopback
|
||||
if (interface.name.toLowerCase().contains('lo')) continue;
|
||||
|
||||
for (final addr in interface.addresses) {
|
||||
if (!addr.isLoopback && addr.type == InternetAddressType.IPv4) {
|
||||
// Prefer names that suggest WiFi/Ethernet
|
||||
if (interface.name.toLowerCase().contains('en') ||
|
||||
interface.name.toLowerCase().contains('wl') ||
|
||||
interface.name.toLowerCase().contains('eth')) {
|
||||
return addr.address;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: return any non-loopback IPv4
|
||||
for (final interface in interfaces) {
|
||||
for (final addr in interface.addresses) {
|
||||
if (!addr.isLoopback && addr.type == InternetAddressType.IPv4) {
|
||||
return addr.address;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw const RemotePeerError(type: RemotePeerErrorType.networkError, message: 'No network interface found');
|
||||
} catch (e) {
|
||||
appLogger.e('CompanionRemote: Failed to get local IP', error: e);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Future<({String sessionId, String pin, String address})> createSession(String deviceName, String platform) async {
|
||||
if (_server != null) {
|
||||
await disconnect();
|
||||
}
|
||||
|
||||
_role = RemoteSessionRole.host;
|
||||
_sessionId = _generateSessionId();
|
||||
_pin = _generatePin();
|
||||
_myPeerId = 'host-$_sessionId';
|
||||
|
||||
try {
|
||||
// Try preferred port first, fallback to OS-assigned port
|
||||
const int preferredPort = 48632;
|
||||
|
||||
try {
|
||||
_server = await HttpServer.bind(InternetAddress.anyIPv4, preferredPort);
|
||||
appLogger.d('CompanionRemote: Server bound to port $preferredPort');
|
||||
} catch (e) {
|
||||
appLogger.w('CompanionRemote: Port $preferredPort occupied, using random port');
|
||||
_server = await HttpServer.bind(InternetAddress.anyIPv4, 0);
|
||||
}
|
||||
|
||||
final localIp = await _getLocalIpAddress();
|
||||
final port = _server!.port;
|
||||
_hostAddress = '$localIp:$port';
|
||||
|
||||
appLogger.d('CompanionRemote: Host server started at $_hostAddress');
|
||||
|
||||
// Listen for WebSocket connections
|
||||
_server!.listen((HttpRequest request) async {
|
||||
if (request.uri.path == '/ws') {
|
||||
try {
|
||||
final socket = await WebSocketTransformer.upgrade(request);
|
||||
_handleNewWebSocketConnection(socket, deviceName, platform);
|
||||
} catch (e) {
|
||||
appLogger.e('CompanionRemote: Failed to upgrade WebSocket', error: e);
|
||||
}
|
||||
} else {
|
||||
request.response.statusCode = HttpStatus.notFound;
|
||||
request.response.close();
|
||||
}
|
||||
});
|
||||
|
||||
_connectionStateController.add(RemoteSessionStatus.connected);
|
||||
|
||||
return (sessionId: _sessionId!, pin: _pin!, address: _hostAddress!);
|
||||
} catch (e) {
|
||||
appLogger.e('CompanionRemote: Failed to create server', error: e);
|
||||
_errorController.add(
|
||||
RemotePeerError(
|
||||
type: RemotePeerErrorType.serverError,
|
||||
message: 'Failed to create server: $e',
|
||||
originalError: e,
|
||||
),
|
||||
);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
void _handleNewWebSocketConnection(WebSocket socket, String hostDeviceName, String hostPlatform) {
|
||||
appLogger.d('CompanionRemote: New WebSocket connection');
|
||||
|
||||
bool isAuthenticated = false;
|
||||
Timer? authTimeout;
|
||||
|
||||
// Authentication timeout
|
||||
authTimeout = Timer(const Duration(seconds: 10), () {
|
||||
if (!isAuthenticated) {
|
||||
appLogger.w('CompanionRemote: Authentication timeout');
|
||||
socket.close(4001, 'Authentication timeout');
|
||||
}
|
||||
});
|
||||
|
||||
socket.listen(
|
||||
(data) {
|
||||
try {
|
||||
final json = jsonDecode(data as String) as Map<String, dynamic>;
|
||||
|
||||
if (!isAuthenticated) {
|
||||
// First message must be authentication
|
||||
if (json['type'] == 'auth') {
|
||||
// Rate limiting: reject if locked out
|
||||
if (_authLockoutUntil != null && DateTime.now().isBefore(_authLockoutUntil!)) {
|
||||
appLogger.w('CompanionRemote: Auth attempt rejected (rate limited)');
|
||||
socket.add(jsonEncode({'type': 'authFailed', 'message': 'Too many attempts. Try again later.'}));
|
||||
socket.close(4005, 'Rate limited');
|
||||
return;
|
||||
}
|
||||
|
||||
final sessionId = json['sessionId'] as String?;
|
||||
final pin = json['pin'] as String?;
|
||||
final deviceName = json['deviceName'] as String?;
|
||||
final platform = json['platform'] as String?;
|
||||
|
||||
if (sessionId == _sessionId && pin == _pin) {
|
||||
_failedAuthAttempts = 0;
|
||||
isAuthenticated = true;
|
||||
authTimeout?.cancel();
|
||||
|
||||
// Close existing client if present
|
||||
if (_clientSocket != null) {
|
||||
appLogger.d('CompanionRemote: Replacing existing client connection');
|
||||
_clientSocket!.close(4004, 'Replaced by new connection');
|
||||
}
|
||||
|
||||
_clientSocket = socket;
|
||||
|
||||
appLogger.d('CompanionRemote: Client authenticated: $deviceName ($platform)');
|
||||
|
||||
// Send auth success
|
||||
socket.add(jsonEncode({'type': 'authSuccess'}));
|
||||
|
||||
// Notify connection
|
||||
final device = RemoteDevice(
|
||||
id: 'remote-client',
|
||||
name: deviceName ?? 'Unknown Device',
|
||||
platform: platform ?? 'unknown',
|
||||
);
|
||||
_deviceConnectedController.add(device);
|
||||
_connectionStateController.add(RemoteSessionStatus.connected);
|
||||
|
||||
// Send device info
|
||||
sendDeviceInfo(hostDeviceName, hostPlatform);
|
||||
|
||||
// Note: Client sends keepalive pings, host only responds with pongs
|
||||
} else {
|
||||
_failedAuthAttempts++;
|
||||
if (_failedAuthAttempts >= _maxFailedAuthAttempts) {
|
||||
_authLockoutUntil = DateTime.now().add(_authLockoutDuration);
|
||||
appLogger.w('CompanionRemote: Too many failed auth attempts, locked out for ${_authLockoutDuration.inSeconds}s');
|
||||
}
|
||||
appLogger.w('CompanionRemote: Invalid credentials (attempt $_failedAuthAttempts/$_maxFailedAuthAttempts)');
|
||||
socket.add(jsonEncode({'type': 'authFailed', 'message': 'Invalid session ID or PIN'}));
|
||||
socket.close(4003, 'Invalid credentials');
|
||||
}
|
||||
} else {
|
||||
appLogger.w('CompanionRemote: Expected auth, got ${json['type']}');
|
||||
socket.close(4002, 'Authentication required');
|
||||
}
|
||||
} else {
|
||||
// Handle regular commands
|
||||
final command = RemoteCommand.fromJson(json);
|
||||
appLogger.d('CompanionRemote: Received command: ${command.type}');
|
||||
|
||||
if (_shouldSendAck(command)) {
|
||||
_sendAck(command);
|
||||
}
|
||||
|
||||
_commandReceivedController.add(command);
|
||||
|
||||
if (command.type == RemoteCommandType.ping) {
|
||||
_sendPong();
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
appLogger.e('CompanionRemote: Failed to process message', error: e);
|
||||
}
|
||||
},
|
||||
onDone: () {
|
||||
authTimeout?.cancel();
|
||||
appLogger.d('CompanionRemote: WebSocket connection closed');
|
||||
if (isAuthenticated) {
|
||||
_clientSocket = null;
|
||||
_deviceDisconnectedController.add(null);
|
||||
_connectionStateController.add(RemoteSessionStatus.disconnected);
|
||||
_stopPingTimer();
|
||||
}
|
||||
},
|
||||
onError: (error) {
|
||||
authTimeout?.cancel();
|
||||
appLogger.e('CompanionRemote: WebSocket error', error: error);
|
||||
_errorController.add(
|
||||
RemotePeerError(
|
||||
type: RemotePeerErrorType.dataChannelError,
|
||||
message: 'WebSocket error: $error',
|
||||
originalError: error,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> joinSession(String sessionId, String pin, String deviceName, String platform, String hostAddress) async {
|
||||
if (_channel != null) {
|
||||
await disconnect();
|
||||
}
|
||||
|
||||
_role = RemoteSessionRole.remote;
|
||||
_sessionId = sessionId.toUpperCase();
|
||||
_pin = pin;
|
||||
_hostAddress = hostAddress;
|
||||
_myPeerId = 'remote-${Random.secure().nextInt(99999)}';
|
||||
|
||||
final completer = Completer<void>();
|
||||
|
||||
try {
|
||||
final url = 'ws://$hostAddress/ws';
|
||||
appLogger.d('CompanionRemote: Connecting to $url');
|
||||
|
||||
_connectionStateController.add(RemoteSessionStatus.connecting);
|
||||
|
||||
_channel = IOWebSocketChannel.connect(Uri.parse(url));
|
||||
|
||||
// Send authentication message
|
||||
final authMessage = jsonEncode({
|
||||
'type': 'auth',
|
||||
'sessionId': _sessionId,
|
||||
'pin': _pin,
|
||||
'deviceName': deviceName,
|
||||
'platform': platform,
|
||||
});
|
||||
_channel!.sink.add(authMessage);
|
||||
|
||||
// Listen for messages
|
||||
_channel!.stream.listen(
|
||||
(data) {
|
||||
try {
|
||||
final json = jsonDecode(data as String) as Map<String, dynamic>;
|
||||
final messageType = json['type'] as String?;
|
||||
|
||||
if (messageType == 'authSuccess') {
|
||||
appLogger.d('CompanionRemote: Authentication successful');
|
||||
|
||||
if (!completer.isCompleted) {
|
||||
completer.complete();
|
||||
}
|
||||
|
||||
final device = RemoteDevice(id: 'host', name: 'Desktop', platform: 'desktop');
|
||||
_deviceConnectedController.add(device);
|
||||
_connectionStateController.add(RemoteSessionStatus.connected);
|
||||
|
||||
// Send device info
|
||||
sendDeviceInfo(deviceName, platform);
|
||||
|
||||
// Start ping timer
|
||||
_startPingTimer();
|
||||
} else if (messageType == 'authFailed') {
|
||||
final message = json['message'] as String? ?? 'Authentication failed';
|
||||
appLogger.w('CompanionRemote: $message');
|
||||
|
||||
if (!completer.isCompleted) {
|
||||
completer.completeError(RemotePeerError(type: RemotePeerErrorType.authFailed, message: message));
|
||||
}
|
||||
|
||||
_errorController.add(RemotePeerError(type: RemotePeerErrorType.authFailed, message: message));
|
||||
_connectionStateController.add(RemoteSessionStatus.error);
|
||||
} else {
|
||||
// Regular command
|
||||
final command = RemoteCommand.fromJson(json);
|
||||
appLogger.d('CompanionRemote: Received command: ${command.type}');
|
||||
|
||||
if (_shouldSendAck(command)) {
|
||||
_sendAck(command);
|
||||
}
|
||||
|
||||
_commandReceivedController.add(command);
|
||||
|
||||
if (command.type == RemoteCommandType.ping) {
|
||||
_sendPong();
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
appLogger.e('CompanionRemote: Failed to parse message', error: e);
|
||||
}
|
||||
},
|
||||
onDone: () {
|
||||
appLogger.d('CompanionRemote: Connection closed');
|
||||
_deviceDisconnectedController.add(null);
|
||||
_connectionStateController.add(RemoteSessionStatus.disconnected);
|
||||
_stopPingTimer();
|
||||
// Reconnection is handled by CompanionRemoteProvider
|
||||
},
|
||||
onError: (error) {
|
||||
appLogger.e('CompanionRemote: Connection error', error: error);
|
||||
|
||||
if (!completer.isCompleted) {
|
||||
completer.completeError(error);
|
||||
}
|
||||
|
||||
_errorController.add(
|
||||
RemotePeerError(
|
||||
type: RemotePeerErrorType.connectionFailed,
|
||||
message: 'Connection error: $error',
|
||||
originalError: error,
|
||||
),
|
||||
);
|
||||
_connectionStateController.add(RemoteSessionStatus.error);
|
||||
},
|
||||
);
|
||||
} catch (e) {
|
||||
appLogger.e('CompanionRemote: Failed to connect', error: e);
|
||||
|
||||
if (!completer.isCompleted) {
|
||||
completer.completeError(e);
|
||||
}
|
||||
|
||||
_errorController.add(
|
||||
RemotePeerError(type: RemotePeerErrorType.connectionFailed, message: 'Failed to connect: $e', originalError: e),
|
||||
);
|
||||
}
|
||||
|
||||
return completer.future.timeout(
|
||||
const Duration(seconds: 15),
|
||||
onTimeout: () async {
|
||||
// Clean up channel on timeout
|
||||
if (_channel != null) {
|
||||
await _channel!.sink.close();
|
||||
_channel = null;
|
||||
}
|
||||
throw const RemotePeerError(type: RemotePeerErrorType.timeout, message: 'Timed out joining session');
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _startPingTimer() {
|
||||
_stopPingTimer();
|
||||
_pingTimer = Timer.periodic(const Duration(seconds: 5), (_) {
|
||||
if (isConnected) {
|
||||
sendCommand(const RemoteCommand(type: RemoteCommandType.ping));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _stopPingTimer() {
|
||||
_pingTimer?.cancel();
|
||||
_pingTimer = null;
|
||||
}
|
||||
|
||||
bool _shouldSendAck(RemoteCommand command) {
|
||||
return command.type != RemoteCommandType.ping &&
|
||||
command.type != RemoteCommandType.pong &&
|
||||
command.type != RemoteCommandType.ack &&
|
||||
command.type != RemoteCommandType.deviceInfo;
|
||||
}
|
||||
|
||||
void _sendAck(RemoteCommand command) {
|
||||
sendCommand(const RemoteCommand(type: RemoteCommandType.ack));
|
||||
}
|
||||
|
||||
void _sendPong() {
|
||||
sendCommand(const RemoteCommand(type: RemoteCommandType.pong));
|
||||
}
|
||||
|
||||
void sendDeviceInfo(String deviceName, String platform) {
|
||||
sendCommand(
|
||||
RemoteCommand(
|
||||
type: RemoteCommandType.deviceInfo,
|
||||
data: {'id': _myPeerId, 'name': deviceName, 'platform': platform, 'role': _role?.name},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void sendCommand(RemoteCommand command) {
|
||||
try {
|
||||
final json = jsonEncode(command.toJson());
|
||||
|
||||
if (_role == RemoteSessionRole.host && _clientSocket != null) {
|
||||
_clientSocket!.add(json);
|
||||
appLogger.d('CompanionRemote: Sent command (host): ${command.type}');
|
||||
} else if (_role == RemoteSessionRole.remote && _channel != null) {
|
||||
_channel!.sink.add(json);
|
||||
appLogger.d('CompanionRemote: Sent command (remote): ${command.type}');
|
||||
} else {
|
||||
appLogger.w('CompanionRemote: No connection to send command');
|
||||
}
|
||||
} catch (e) {
|
||||
appLogger.e('CompanionRemote: Failed to send command', error: e);
|
||||
_errorController.add(
|
||||
RemotePeerError(
|
||||
type: RemotePeerErrorType.dataChannelError,
|
||||
message: 'Failed to send command: $e',
|
||||
originalError: e,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> disconnect() async {
|
||||
appLogger.d('CompanionRemote: Disconnecting');
|
||||
|
||||
_stopPingTimer();
|
||||
|
||||
if (_clientSocket != null) {
|
||||
await _clientSocket!.close();
|
||||
_clientSocket = null;
|
||||
}
|
||||
|
||||
if (_channel != null) {
|
||||
await _channel!.sink.close();
|
||||
_channel = null;
|
||||
}
|
||||
|
||||
if (_server != null) {
|
||||
await _server!.close();
|
||||
_server = null;
|
||||
}
|
||||
|
||||
_sessionId = null;
|
||||
_pin = null;
|
||||
_myPeerId = null;
|
||||
_hostAddress = null;
|
||||
_role = null;
|
||||
|
||||
_connectionStateController.add(RemoteSessionStatus.disconnected);
|
||||
}
|
||||
|
||||
Future<void> dispose() async {
|
||||
await disconnect();
|
||||
await _commandReceivedController.close();
|
||||
await _deviceConnectedController.close();
|
||||
await _deviceDisconnectedController.close();
|
||||
await _errorController.close();
|
||||
await _connectionStateController.close();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
import '../../models/companion_remote/remote_command.dart';
|
||||
import '../../models/companion_remote/remote_command_type.dart';
|
||||
import '../../utils/app_logger.dart';
|
||||
import '../../utils/key_event_simulator.dart';
|
||||
|
||||
class CompanionRemoteReceiver {
|
||||
CompanionRemoteReceiver._();
|
||||
|
||||
static CompanionRemoteReceiver? _instance;
|
||||
|
||||
static CompanionRemoteReceiver get instance {
|
||||
_instance ??= CompanionRemoteReceiver._();
|
||||
return _instance!;
|
||||
}
|
||||
|
||||
/// Called on any remote input so InputModeTracker can switch to keyboard mode.
|
||||
/// Same pattern as [GamepadService.onGamepadInput].
|
||||
static VoidCallback? onRemoteInput;
|
||||
|
||||
VoidCallback? onTabNext;
|
||||
VoidCallback? onTabPrevious;
|
||||
VoidCallback? onTabDiscover;
|
||||
VoidCallback? onTabLibraries;
|
||||
VoidCallback? onTabSearch;
|
||||
VoidCallback? onTabDownloads;
|
||||
VoidCallback? onTabSettings;
|
||||
VoidCallback? onHome;
|
||||
void Function(String? query)? onSearchAction;
|
||||
VoidCallback? onNextTrack;
|
||||
VoidCallback? onPreviousTrack;
|
||||
VoidCallback? onStop;
|
||||
VoidCallback? onSeekForward;
|
||||
VoidCallback? onSeekBackward;
|
||||
VoidCallback? onVolumeUp;
|
||||
VoidCallback? onVolumeDown;
|
||||
VoidCallback? onVolumeMute;
|
||||
VoidCallback? onSubtitles;
|
||||
VoidCallback? onAudioTracks;
|
||||
VoidCallback? onFullscreen;
|
||||
|
||||
void handleCommand(RemoteCommand command, BuildContext? context) {
|
||||
appLogger.d('CompanionRemoteReceiver: Handling command: ${command.type}');
|
||||
|
||||
// Switch to keyboard mode so focus visuals render
|
||||
onRemoteInput?.call();
|
||||
_setTraditionalFocusHighlight();
|
||||
scheduleFrameIfIdle();
|
||||
|
||||
switch (command.type) {
|
||||
case RemoteCommandType.dpadUp:
|
||||
simulateKeyPress(LogicalKeyboardKey.arrowUp);
|
||||
case RemoteCommandType.dpadDown:
|
||||
simulateKeyPress(LogicalKeyboardKey.arrowDown);
|
||||
case RemoteCommandType.dpadLeft:
|
||||
simulateKeyPress(LogicalKeyboardKey.arrowLeft);
|
||||
case RemoteCommandType.dpadRight:
|
||||
simulateKeyPress(LogicalKeyboardKey.arrowRight);
|
||||
case RemoteCommandType.select:
|
||||
simulateKeyPress(LogicalKeyboardKey.enter);
|
||||
case RemoteCommandType.back:
|
||||
simulateKeyPress(LogicalKeyboardKey.escape);
|
||||
case RemoteCommandType.contextMenu:
|
||||
simulateKeyPress(LogicalKeyboardKey.contextMenu);
|
||||
|
||||
case RemoteCommandType.play:
|
||||
simulateKeyPress(LogicalKeyboardKey.space);
|
||||
case RemoteCommandType.pause:
|
||||
simulateKeyPress(LogicalKeyboardKey.space);
|
||||
case RemoteCommandType.playPause:
|
||||
simulateKeyPress(LogicalKeyboardKey.space);
|
||||
case RemoteCommandType.seekForward:
|
||||
onSeekForward?.call();
|
||||
case RemoteCommandType.seekBackward:
|
||||
onSeekBackward?.call();
|
||||
|
||||
case RemoteCommandType.volumeUp:
|
||||
onVolumeUp?.call();
|
||||
case RemoteCommandType.volumeDown:
|
||||
onVolumeDown?.call();
|
||||
case RemoteCommandType.volumeMute:
|
||||
onVolumeMute?.call();
|
||||
|
||||
case RemoteCommandType.tabNext:
|
||||
onTabNext?.call();
|
||||
case RemoteCommandType.tabPrevious:
|
||||
onTabPrevious?.call();
|
||||
case RemoteCommandType.tabDiscover:
|
||||
onTabDiscover?.call();
|
||||
case RemoteCommandType.tabLibraries:
|
||||
onTabLibraries?.call();
|
||||
case RemoteCommandType.tabSearch:
|
||||
onTabSearch?.call();
|
||||
case RemoteCommandType.tabDownloads:
|
||||
onTabDownloads?.call();
|
||||
case RemoteCommandType.tabSettings:
|
||||
onTabSettings?.call();
|
||||
|
||||
case RemoteCommandType.home:
|
||||
onHome?.call();
|
||||
case RemoteCommandType.search:
|
||||
final query = command.data?['query'] as String?;
|
||||
onSearchAction?.call(query);
|
||||
|
||||
case RemoteCommandType.stop:
|
||||
onStop?.call();
|
||||
case RemoteCommandType.nextTrack:
|
||||
onNextTrack?.call();
|
||||
case RemoteCommandType.previousTrack:
|
||||
onPreviousTrack?.call();
|
||||
|
||||
case RemoteCommandType.subtitles:
|
||||
onSubtitles?.call();
|
||||
case RemoteCommandType.audioTracks:
|
||||
onAudioTracks?.call();
|
||||
|
||||
case RemoteCommandType.fullscreen:
|
||||
if (onFullscreen != null) {
|
||||
onFullscreen!.call();
|
||||
} else {
|
||||
simulateKeyPress(LogicalKeyboardKey.keyF);
|
||||
}
|
||||
|
||||
case RemoteCommandType.ping:
|
||||
case RemoteCommandType.pong:
|
||||
case RemoteCommandType.ack:
|
||||
case RemoteCommandType.deviceInfo:
|
||||
case RemoteCommandType.disconnect:
|
||||
case RemoteCommandType.syncState:
|
||||
break;
|
||||
|
||||
default:
|
||||
appLogger.w('CompanionRemoteReceiver: Unhandled command type: ${command.type}');
|
||||
}
|
||||
}
|
||||
|
||||
void _setTraditionalFocusHighlight() {
|
||||
if (FocusManager.instance.highlightStrategy != FocusHighlightStrategy.alwaysTraditional) {
|
||||
FocusManager.instance.highlightStrategy = FocusHighlightStrategy.alwaysTraditional;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,11 +2,12 @@ import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/scheduler.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:universal_gamepad/universal_gamepad.dart';
|
||||
|
||||
import '../utils/app_logger.dart';
|
||||
import '../utils/key_event_simulator.dart' as key_sim;
|
||||
|
||||
/// Service that bridges gamepad input to Flutter's focus navigation system.
|
||||
///
|
||||
@@ -106,7 +107,7 @@ class GamepadService {
|
||||
if (event.pressed) {
|
||||
onGamepadInput?.call();
|
||||
_setTraditionalFocusHighlight();
|
||||
_scheduleFrameIfIdle();
|
||||
key_sim.scheduleFrameIfIdle();
|
||||
}
|
||||
|
||||
final wasPressed = _pressedButtons.contains(event.button);
|
||||
@@ -170,7 +171,7 @@ class GamepadService {
|
||||
if (event.value.abs() > 0.3) {
|
||||
onGamepadInput?.call();
|
||||
_setTraditionalFocusHighlight();
|
||||
_scheduleFrameIfIdle();
|
||||
SchedulerBinding.instance.ensureVisualUpdate();
|
||||
}
|
||||
|
||||
switch (event.axis) {
|
||||
@@ -342,13 +343,4 @@ class GamepadService {
|
||||
FocusManager.instance.highlightStrategy = FocusHighlightStrategy.alwaysTraditional;
|
||||
}
|
||||
}
|
||||
|
||||
// Force a frame when the engine is idle so focus visuals update immediately
|
||||
// on gamepad input (desktop may not wake up without mouse/keyboard activity).
|
||||
void _scheduleFrameIfIdle() {
|
||||
final scheduler = SchedulerBinding.instance;
|
||||
if (scheduler.schedulerPhase == SchedulerPhase.idle) {
|
||||
scheduler.scheduleFrame();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import 'package:flutter/scheduler.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
/// Shared utility for simulating key press events through the focus tree.
|
||||
///
|
||||
/// Used by both [CompanionRemoteReceiver] and [GamepadService] to translate
|
||||
/// external input (remote commands, gamepad buttons) into focus-tree key events.
|
||||
void simulateKeyPress(LogicalKeyboardKey logicalKey) {
|
||||
SchedulerBinding.instance.addPostFrameCallback((_) {
|
||||
final focusNode = FocusManager.instance.primaryFocus;
|
||||
if (focusNode == null) return;
|
||||
|
||||
final physicalKey = _getPhysicalKey(logicalKey);
|
||||
|
||||
final keyDownEvent = KeyDownEvent(
|
||||
physicalKey: physicalKey,
|
||||
logicalKey: logicalKey,
|
||||
timeStamp: Duration(milliseconds: DateTime.now().millisecondsSinceEpoch),
|
||||
);
|
||||
|
||||
// Walk up the focus tree dispatching the key event
|
||||
FocusNode? node = focusNode;
|
||||
KeyEventResult result = KeyEventResult.ignored;
|
||||
|
||||
while (node != null && result != KeyEventResult.handled) {
|
||||
if (node.onKeyEvent != null) {
|
||||
result = node.onKeyEvent!(node, keyDownEvent);
|
||||
}
|
||||
node = node.parent;
|
||||
}
|
||||
|
||||
// Send key up event
|
||||
final keyUpEvent = KeyUpEvent(
|
||||
physicalKey: physicalKey,
|
||||
logicalKey: logicalKey,
|
||||
timeStamp: Duration(milliseconds: DateTime.now().millisecondsSinceEpoch),
|
||||
);
|
||||
|
||||
node = focusNode;
|
||||
while (node != null) {
|
||||
if (node.onKeyEvent != null) {
|
||||
final upResult = node.onKeyEvent!(node, keyUpEvent);
|
||||
if (upResult == KeyEventResult.handled) break;
|
||||
}
|
||||
node = node.parent;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Force a frame when the engine is idle so focus visuals update immediately
|
||||
/// on external input (desktop may not wake up without mouse/keyboard activity).
|
||||
void scheduleFrameIfIdle() {
|
||||
if (SchedulerBinding.instance.schedulerPhase == SchedulerPhase.idle) {
|
||||
SchedulerBinding.instance.scheduleFrame();
|
||||
}
|
||||
}
|
||||
|
||||
PhysicalKeyboardKey _getPhysicalKey(LogicalKeyboardKey logicalKey) {
|
||||
if (logicalKey == LogicalKeyboardKey.arrowUp) return PhysicalKeyboardKey.arrowUp;
|
||||
if (logicalKey == LogicalKeyboardKey.arrowDown) return PhysicalKeyboardKey.arrowDown;
|
||||
if (logicalKey == LogicalKeyboardKey.arrowLeft) return PhysicalKeyboardKey.arrowLeft;
|
||||
if (logicalKey == LogicalKeyboardKey.arrowRight) return PhysicalKeyboardKey.arrowRight;
|
||||
if (logicalKey == LogicalKeyboardKey.enter) return PhysicalKeyboardKey.enter;
|
||||
if (logicalKey == LogicalKeyboardKey.escape) return PhysicalKeyboardKey.escape;
|
||||
if (logicalKey == LogicalKeyboardKey.space) return PhysicalKeyboardKey.space;
|
||||
if (logicalKey == LogicalKeyboardKey.contextMenu) return PhysicalKeyboardKey.contextMenu;
|
||||
if (logicalKey == LogicalKeyboardKey.audioVolumeUp) return PhysicalKeyboardKey.audioVolumeUp;
|
||||
if (logicalKey == LogicalKeyboardKey.audioVolumeDown) return PhysicalKeyboardKey.audioVolumeDown;
|
||||
if (logicalKey == LogicalKeyboardKey.audioVolumeMute) return PhysicalKeyboardKey.audioVolumeMute;
|
||||
if (logicalKey == LogicalKeyboardKey.keyF) return PhysicalKeyboardKey.keyF;
|
||||
if (logicalKey == LogicalKeyboardKey.gameButtonA) return PhysicalKeyboardKey.gameButtonA;
|
||||
if (logicalKey == LogicalKeyboardKey.gameButtonB) return PhysicalKeyboardKey.gameButtonB;
|
||||
if (logicalKey == LogicalKeyboardKey.gameButtonX) return PhysicalKeyboardKey.gameButtonX;
|
||||
return PhysicalKeyboardKey.enter;
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:qr_flutter/qr_flutter.dart';
|
||||
|
||||
import '../../providers/companion_remote_provider.dart';
|
||||
import '../../utils/app_logger.dart';
|
||||
|
||||
class RemoteSessionDialog extends StatefulWidget {
|
||||
const RemoteSessionDialog({super.key});
|
||||
|
||||
@override
|
||||
State<RemoteSessionDialog> createState() => _RemoteSessionDialogState();
|
||||
|
||||
static Future<void> show(BuildContext context) {
|
||||
return showDialog<void>(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (context) => const RemoteSessionDialog(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _RemoteSessionDialogState extends State<RemoteSessionDialog> {
|
||||
bool _isCreatingSession = false;
|
||||
String? _errorMessage;
|
||||
String? _hostAddress; // Format: "ip:port"
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// Defer to avoid notifyListeners() during build phase
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _createSession());
|
||||
}
|
||||
|
||||
Future<void> _createSession() async {
|
||||
setState(() {
|
||||
_isCreatingSession = true;
|
||||
_errorMessage = null;
|
||||
_hostAddress = null;
|
||||
});
|
||||
|
||||
try {
|
||||
final provider = context.read<CompanionRemoteProvider>();
|
||||
final result = await provider.createSession();
|
||||
|
||||
setState(() {
|
||||
_isCreatingSession = false;
|
||||
_hostAddress = result.address;
|
||||
});
|
||||
} catch (e) {
|
||||
appLogger.e('Failed to create companion remote session', error: e);
|
||||
setState(() {
|
||||
_isCreatingSession = false;
|
||||
_errorMessage = e.toString();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void _copyToClipboard(String text, String label) {
|
||||
Clipboard.setData(ClipboardData(text: text));
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text('$label copied to clipboard'), duration: const Duration(seconds: 2)));
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Consumer<CompanionRemoteProvider>(
|
||||
builder: (context, provider, child) {
|
||||
if (_isCreatingSession) {
|
||||
return Dialog(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(32.0),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const CircularProgressIndicator(),
|
||||
const SizedBox(height: 16),
|
||||
Text('Creating remote session...', style: Theme.of(context).textTheme.titleMedium),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (_errorMessage != null) {
|
||||
return AlertDialog(
|
||||
title: const Text('Error'),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text('Failed to create remote session:'),
|
||||
const SizedBox(height: 8),
|
||||
Text(_errorMessage!, style: const TextStyle(fontFamily: 'monospace')),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.of(context).pop(), child: const Text('Close')),
|
||||
TextButton(onPressed: _createSession, child: const Text('Retry')),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
final session = provider.session;
|
||||
if (session == null || _hostAddress == null) {
|
||||
return AlertDialog(
|
||||
title: const Text('Error'),
|
||||
content: const Text('No session available'),
|
||||
actions: [TextButton(onPressed: () => Navigator.of(context).pop(), child: const Text('Close'))],
|
||||
);
|
||||
}
|
||||
|
||||
// Parse IP and port from hostAddress
|
||||
final addressParts = _hostAddress!.split(':');
|
||||
final ip = addressParts[0];
|
||||
final port = addressParts[1];
|
||||
|
||||
// New QR format: ip|port|sessionId|pin (using pipe separator)
|
||||
final qrData = '$ip|$port|${session.sessionId}|${session.pin}';
|
||||
|
||||
return Dialog(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 500),
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(24.0),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
const Icon(Icons.phone_android, size: 32),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('Companion Remote', style: Theme.of(context).textTheme.headlineSmall),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
session.connectedDevice != null
|
||||
? 'Connected to ${session.connectedDevice!.name}'
|
||||
: 'Waiting for connection...',
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: session.connectedDevice != null
|
||||
? Colors.green
|
||||
: Theme.of(context).textTheme.bodySmall?.color,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
IconButton(icon: const Icon(Icons.close), onPressed: () => Navigator.of(context).pop()),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
if (session.connectedDevice == null) ...[
|
||||
Text('Scan QR Code', style: Theme.of(context).textTheme.titleMedium, textAlign: TextAlign.center),
|
||||
const SizedBox(height: 16),
|
||||
Center(
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(12)),
|
||||
child: QrImageView(
|
||||
data: qrData,
|
||||
version: QrVersions.auto,
|
||||
size: 200,
|
||||
backgroundColor: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
const Divider(),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Or enter manually',
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_buildCodeCard(
|
||||
context,
|
||||
'Host Address',
|
||||
_hostAddress!,
|
||||
onCopy: () => _copyToClipboard(_hostAddress!, 'Host Address'),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_buildCodeCard(
|
||||
context,
|
||||
'Session ID',
|
||||
session.sessionId,
|
||||
onCopy: () => _copyToClipboard(session.sessionId, 'Session ID'),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_buildCodeCard(context, 'PIN', session.pin, onCopy: () => _copyToClipboard(session.pin, 'PIN')),
|
||||
] else ...[
|
||||
Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
children: [
|
||||
const Icon(Icons.check_circle, color: Colors.green, size: 48),
|
||||
const SizedBox(height: 8),
|
||||
Text('Connected', style: Theme.of(context).textTheme.titleLarge),
|
||||
const SizedBox(height: 8),
|
||||
Text(session.connectedDevice!.name, style: Theme.of(context).textTheme.bodyLarge),
|
||||
Text(session.connectedDevice!.platform, style: Theme.of(context).textTheme.bodySmall),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Use your mobile device to control this app',
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 24),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
if (session.connectedDevice != null)
|
||||
TextButton.icon(
|
||||
onPressed: () async {
|
||||
await provider.leaveSession();
|
||||
await _createSession();
|
||||
},
|
||||
icon: const Icon(Icons.refresh),
|
||||
label: const Text('New Session'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
TextButton(
|
||||
onPressed: () async {
|
||||
await provider.leaveSession();
|
||||
if (context.mounted) {
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
},
|
||||
child: const Text('Disconnect'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
FilledButton(onPressed: () => Navigator.of(context).pop(), child: const Text('Minimize')),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCodeCard(BuildContext context, String label, String code, {VoidCallback? onCopy}) {
|
||||
return Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(label, style: Theme.of(context).textTheme.bodySmall),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
code,
|
||||
style: Theme.of(context).textTheme.titleLarge?.copyWith(fontFamily: 'monospace', letterSpacing: 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
IconButton(icon: const Icon(Icons.copy), onPressed: onCopy, tooltip: 'Copy to clipboard'),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -258,6 +258,7 @@ class _PlexVideoControlsState extends State<PlexVideoControls> with WindowListen
|
||||
_initAlwaysOnTopState();
|
||||
}
|
||||
|
||||
|
||||
// Focus play/pause button on first frame if in keyboard mode
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_focusPlayPauseIfKeyboardMode();
|
||||
@@ -733,7 +734,12 @@ class _PlexVideoControlsState extends State<PlexVideoControls> with WindowListen
|
||||
}
|
||||
|
||||
void _updateTrafficLightVisibility() async {
|
||||
await MacOSWindowService.setTrafficLightsVisible(_showControls);
|
||||
// When maximized or fullscreen, always keep traffic lights visible so the
|
||||
// user can reach them without the controls-hide-on-mouse-leave race.
|
||||
// In normal windowed mode, toggle with controls as before.
|
||||
final isMaximizedOrFullscreen = await windowManager.isMaximized() || await windowManager.isFullScreen();
|
||||
final visible = isMaximizedOrFullscreen ? true : _showControls;
|
||||
await MacOSWindowService.setTrafficLightsVisible(visible);
|
||||
}
|
||||
|
||||
/// Check whether PiP is supported on this device
|
||||
|
||||
@@ -9,8 +9,10 @@ import connectivity_plus
|
||||
import device_info_plus
|
||||
import file_picker
|
||||
import in_app_review
|
||||
import mobile_scanner
|
||||
import os_media_controls
|
||||
import package_info_plus
|
||||
import path_provider_foundation
|
||||
import screen_retriever_macos
|
||||
import shared_preferences_foundation
|
||||
import sqflite_darwin
|
||||
@@ -25,8 +27,10 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
||||
DeviceInfoPlusMacosPlugin.register(with: registry.registrar(forPlugin: "DeviceInfoPlusMacosPlugin"))
|
||||
FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin"))
|
||||
InAppReviewPlugin.register(with: registry.registrar(forPlugin: "InAppReviewPlugin"))
|
||||
MobileScannerPlugin.register(with: registry.registrar(forPlugin: "MobileScannerPlugin"))
|
||||
OsMediaControlsPlugin.register(with: registry.registrar(forPlugin: "OsMediaControlsPlugin"))
|
||||
FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin"))
|
||||
PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))
|
||||
ScreenRetrieverMacosPlugin.register(with: registry.registrar(forPlugin: "ScreenRetrieverMacosPlugin"))
|
||||
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
|
||||
SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin"))
|
||||
|
||||
@@ -8,10 +8,15 @@ PODS:
|
||||
- FlutterMacOS (1.0.0)
|
||||
- in_app_review (2.0.0):
|
||||
- FlutterMacOS
|
||||
- mobile_scanner (6.0.2):
|
||||
- FlutterMacOS
|
||||
- os_media_controls (0.0.1):
|
||||
- FlutterMacOS
|
||||
- package_info_plus (0.0.1):
|
||||
- FlutterMacOS
|
||||
- path_provider_foundation (0.0.1):
|
||||
- Flutter
|
||||
- FlutterMacOS
|
||||
- screen_retriever_macos (0.0.1):
|
||||
- FlutterMacOS
|
||||
- shared_preferences_foundation (0.0.1):
|
||||
@@ -60,8 +65,10 @@ DEPENDENCIES:
|
||||
- file_picker (from `Flutter/ephemeral/.symlinks/plugins/file_picker/macos`)
|
||||
- FlutterMacOS (from `Flutter/ephemeral`)
|
||||
- in_app_review (from `Flutter/ephemeral/.symlinks/plugins/in_app_review/macos`)
|
||||
- mobile_scanner (from `Flutter/ephemeral/.symlinks/plugins/mobile_scanner/macos`)
|
||||
- os_media_controls (from `Flutter/ephemeral/.symlinks/plugins/os_media_controls/macos`)
|
||||
- package_info_plus (from `Flutter/ephemeral/.symlinks/plugins/package_info_plus/macos`)
|
||||
- path_provider_foundation (from `Flutter/ephemeral/.symlinks/plugins/path_provider_foundation/darwin`)
|
||||
- screen_retriever_macos (from `Flutter/ephemeral/.symlinks/plugins/screen_retriever_macos/macos`)
|
||||
- shared_preferences_foundation (from `Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin`)
|
||||
- sqflite_darwin (from `Flutter/ephemeral/.symlinks/plugins/sqflite_darwin/darwin`)
|
||||
@@ -86,10 +93,14 @@ EXTERNAL SOURCES:
|
||||
:path: Flutter/ephemeral
|
||||
in_app_review:
|
||||
:path: Flutter/ephemeral/.symlinks/plugins/in_app_review/macos
|
||||
mobile_scanner:
|
||||
:path: Flutter/ephemeral/.symlinks/plugins/mobile_scanner/macos
|
||||
os_media_controls:
|
||||
:path: Flutter/ephemeral/.symlinks/plugins/os_media_controls/macos
|
||||
package_info_plus:
|
||||
:path: Flutter/ephemeral/.symlinks/plugins/package_info_plus/macos
|
||||
path_provider_foundation:
|
||||
:path: Flutter/ephemeral/.symlinks/plugins/path_provider_foundation/darwin
|
||||
screen_retriever_macos:
|
||||
:path: Flutter/ephemeral/.symlinks/plugins/screen_retriever_macos/macos
|
||||
shared_preferences_foundation:
|
||||
@@ -113,8 +124,10 @@ SPEC CHECKSUMS:
|
||||
file_picker: 7584aae6fa07a041af2b36a2655122d42f578c1a
|
||||
FlutterMacOS: d0db08ddef1a9af05a5ec4b724367152bb0500b1
|
||||
in_app_review: 66e7680752b632d83f4f0e88b34d52ed303fbff4
|
||||
mobile_scanner: 0e365ed56cad24f28c0fd858ca04edefb40dfac3
|
||||
os_media_controls: c07c04c4afdf59dda0a3f398457a46823c4ce0ed
|
||||
package_info_plus: f0052d280d17aa382b932f399edf32507174e870
|
||||
path_provider_foundation: bb55f6dbba17d0dccd6737fe6f7f34fbd0376880
|
||||
screen_retriever_macos: 452e51764a9e1cdb74b3c541238795849f21557f
|
||||
shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb
|
||||
sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0
|
||||
|
||||
+136
-112
@@ -5,26 +5,26 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: _fe_analyzer_shared
|
||||
sha256: c209688d9f5a5f26b2fb47a188131a6fb9e876ae9e47af3737c0b4f58a93470d
|
||||
sha256: da0d9209ca76bde579f2da330aeb9df62b6319c834fa7baae052021b0462401f
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "91.0.0"
|
||||
version: "85.0.0"
|
||||
analyzer:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: analyzer
|
||||
sha256: f51c8499b35f9b26820cfe914828a6a98a94efd5cc78b37bb7d03debae3a1d08
|
||||
sha256: "974859dc0ff5f37bc4313244b3218c791810d03ab3470a579580279ba971a48d"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "8.4.1"
|
||||
version: "7.7.1"
|
||||
analyzer_plugin:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: analyzer_plugin
|
||||
sha256: "825071d553c4aef2252196d46a665fbd8e0cb06de07725f25d1b29bd18d65fff"
|
||||
sha256: a5ab7590c27b779f3d4de67f31c4109dbe13dd7339f86461a6f2a8ab2594d8ce
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.13.6"
|
||||
version: "0.13.4"
|
||||
ansicolor:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -69,18 +69,18 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: build
|
||||
sha256: "275bf6bb2a00a9852c28d4e0b410da1d833a734d57d39d44f94bfc895a484ec3"
|
||||
sha256: "51dc711996cbf609b90cbe5b335bbce83143875a9d58e4b5c6d3c4f684d3dda7"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.0.4"
|
||||
version: "2.5.4"
|
||||
build_config:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: build_config
|
||||
sha256: "4f64382b97504dc2fcdf487d5aae33418e08b4703fc21249e4db6d804a4d0187"
|
||||
sha256: "4ae2de3e1e67ea270081eaee972e1bd8f027d459f249e0f1186730784c2e7e33"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.2.0"
|
||||
version: "1.1.2"
|
||||
build_daemon:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -89,14 +89,30 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.1.1"
|
||||
build_resolvers:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: build_resolvers
|
||||
sha256: ee4257b3f20c0c90e72ed2b57ad637f694ccba48839a821e87db762548c22a62
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.5.4"
|
||||
build_runner:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
name: build_runner
|
||||
sha256: ac78098de97893812b7aff1154f29008fa2464cad9e8e7044d39bc905dad4fbc
|
||||
sha256: "382a4d649addbfb7ba71a3631df0ec6a45d5ab9b098638144faf27f02778eb53"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.11.0"
|
||||
version: "2.5.4"
|
||||
build_runner_core:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: build_runner_core
|
||||
sha256: "85fbbb1036d576d966332a3f5ce83f2ce66a40bea1a94ad2d5fc29a19a0d3792"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "9.1.2"
|
||||
built_collection:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -109,10 +125,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: built_value
|
||||
sha256: "7931c90b84bc573fef103548e354258ae4c9d28d140e41961df6843c5d60d4d8"
|
||||
sha256: a30f0a0e38671e89a492c44d005b5545b830a961575bbd8336d42869ff71066d
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "8.12.3"
|
||||
version: "8.12.0"
|
||||
cached_network_image:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -137,6 +153,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.3.1"
|
||||
chalkdart:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: chalkdart
|
||||
sha256: "7ffc6bd39c81453fb9ba8dbce042a9c960219b75ea1c07196a7fa41c2fab9e86"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.5"
|
||||
characters:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -177,22 +201,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.2"
|
||||
code_assets:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: code_assets
|
||||
sha256: "83ccdaa064c980b5596c35dd64a8d3ecc68620174ab9b90b6343b753aa721687"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.0"
|
||||
code_builder:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: code_builder
|
||||
sha256: "6a6cab2ba4680d6423f34a9b972a4c9a94ebe1b62ecec4e1a1f2cba91fd1319d"
|
||||
sha256: "11654819532ba94c34de52ff5feb52bd81cba1de00ef2ed622fd50295f9d4243"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.11.1"
|
||||
version: "4.11.0"
|
||||
collection:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -229,10 +245,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: cross_file
|
||||
sha256: "28bb3ae56f117b5aec029d702a90f57d285cd975c3c5c281eaca38dbc47c5937"
|
||||
sha256: "701dcfc06da0882883a2657c445103380e53e647060ad8d9dfb710c100996608"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.3.5+2"
|
||||
version: "0.3.5+1"
|
||||
crypto:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -261,10 +277,10 @@ packages:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
name: dart_code_linter
|
||||
sha256: "1b53722d9933a5f5d4580acc29c7f16b1fde66d21d1ecf7bb2a811caf3a42b42"
|
||||
sha256: "9456e0a7508b0d76be301dc2f73be37e601019e85c5f59e6a5cc460af90f7dcd"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.2.1"
|
||||
version: "3.1.1"
|
||||
dart_discord_presence:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -277,18 +293,18 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: dart_style
|
||||
sha256: a9c30492da18ff84efe2422ba2d319a89942d93e58eb0b73d32abe822ef54b7b
|
||||
sha256: "8a0e5fba27e8ee025d2ffb4ee820b4e6e2cf5e4246a6b1a477eb66866947e0bb"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.3"
|
||||
version: "3.1.1"
|
||||
dbus:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: dbus
|
||||
sha256: d0c98dcd4f5169878b6cf8f6e0a52403a9dff371a3e2f019697accbf6f44a270
|
||||
sha256: "79e0c23480ff85dc68de79e2cd6334add97e48f7f4865d17686dd6ea81a47e8c"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.7.12"
|
||||
version: "0.7.11"
|
||||
device_info_plus:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -309,10 +325,10 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: dio
|
||||
sha256: b9d46faecab38fc8cc286f80bc4d61a3bb5d4ac49e51ed877b4d6706efe57b25
|
||||
sha256: d90ee57923d1828ac14e492ca49440f65477f4bb1263575900be731a3dac66a9
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "5.9.1"
|
||||
version: "5.9.0"
|
||||
dio_web_adapter:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -325,18 +341,18 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: drift
|
||||
sha256: "970cd188fddb111b26ea6a9b07a62bf5c2432d74147b8122c67044ae3b97e99e"
|
||||
sha256: "540cf382a3bfa99b76e51514db5b0ebcd81ce3679b7c1c9cb9478ff3735e47a1"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.31.0"
|
||||
version: "2.28.2"
|
||||
drift_dev:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
name: drift_dev
|
||||
sha256: "917184b2fb867b70a548a83bf0d36268423b38d39968c06cce4905683da49587"
|
||||
sha256: "68c138e884527d2bd61df2ade276c3a144df84d1adeb0ab8f3196b5afe021bd4"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.31.0"
|
||||
version: "2.28.0"
|
||||
duration:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -357,10 +373,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: ffi
|
||||
sha256: d07d37192dbf97461359c1518788f203b0c9102cfd2c35a716b823741219542c
|
||||
sha256: "289279317b4b16eb2bb7e271abccd4bf84ec9bdcbe999e278a94b804f5630418"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.5"
|
||||
version: "2.1.4"
|
||||
file:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -389,18 +405,18 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: flex_color_picker
|
||||
sha256: a0979dd61f21b634717b98eb4ceaed2bfe009fe020ce8597aaf164b9eeb57aaa
|
||||
sha256: f5b0b53d4ae0d59b1e28dfc21d5398e5028cf8e764518e491a52fd050aa23881
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.8.0"
|
||||
version: "3.7.2"
|
||||
flex_seed_scheme:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flex_seed_scheme
|
||||
sha256: a3183753bbcfc3af106224bff3ab3e1844b73f58062136b7499919f49f3667e7
|
||||
sha256: "828291a5a4d4283590541519d8b57821946660ac61d2e07d955f81cfcab22e5d"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.0.1"
|
||||
version: "3.6.1"
|
||||
flutter:
|
||||
dependency: "direct main"
|
||||
description: flutter
|
||||
@@ -461,6 +477,14 @@ packages:
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
frontend_server_client:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: frontend_server_client
|
||||
sha256: f64a0333a82f30b0cca061bc3d143813a486dc086b574bfb233b7c1372427694
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.0.0"
|
||||
glob:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -477,14 +501,6 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.3.2"
|
||||
hooks:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: hooks
|
||||
sha256: "7a08a0d684cb3b8fb604b78455d5d352f502b68079f7b80b831c62220ab0a4f6"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.1"
|
||||
html:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -521,10 +537,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: image
|
||||
sha256: "492bd52f6c4fbb6ee41f781ff27765ce5f627910e1e0cbecfa3d9add5562604c"
|
||||
sha256: "4e973fcf4caae1a4be2fa0a13157aa38a8f9cb049db6529aa00b4d71abc4d928"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.7.2"
|
||||
version: "4.5.4"
|
||||
in_app_review:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -557,6 +573,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.5"
|
||||
js:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: js
|
||||
sha256: f2c445dce49627136094980615a031419f7f3eb393237e4ecd97ac15dea343f3
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.6.7"
|
||||
json_annotation:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -569,10 +593,10 @@ packages:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
name: json_serializable
|
||||
sha256: c5b2ee75210a0f263c6c7b9eeea80553dbae96ea1bf57f02484e806a3ffdffa3
|
||||
sha256: c50ef5fc083d5b5e12eef489503ba3bf5ccc899e487d691584699b4bdefeea8c
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.11.2"
|
||||
version: "6.9.5"
|
||||
leak_tracker:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -641,10 +665,10 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: material_symbols_icons
|
||||
sha256: c62b15f2b3de98d72cbff0148812f5ef5159f05e61fc9f9a089ec2bb234df082
|
||||
sha256: "02555a48e1ec02b16e532dfd4ef13c4f6bf7ec7c20230e58e56641a393433dc3"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.2906.0"
|
||||
version: "4.2892.0"
|
||||
meta:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -661,14 +685,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.0"
|
||||
native_toolchain_c:
|
||||
dependency: transitive
|
||||
mobile_scanner:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: native_toolchain_c
|
||||
sha256: "89e83885ba09da5fdf2cdacc8002a712ca238c28b7f717910b34bcd27b0d03ac"
|
||||
name: mobile_scanner
|
||||
sha256: "0b466a0a8a211b366c2e87f3345715faef9b6011c7147556ad22f37de6ba3173"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.17.4"
|
||||
version: "6.0.11"
|
||||
nested:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -685,14 +709,6 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.5.0"
|
||||
objective_c:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: objective_c
|
||||
sha256: "100a1c87616ab6ed41ec263b083c0ef3261ee6cd1dc3b0f35f8ddfa4f996fe52"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "9.3.0"
|
||||
octo_image:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -762,18 +778,18 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: path_provider_android
|
||||
sha256: f2c65e21139ce2c3dad46922be8272bb5963516045659e71bb16e151c93b580e
|
||||
sha256: e122c5ea805bb6773bb12ce667611265980940145be920cd09a4b0ec0285cb16
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.2.22"
|
||||
version: "2.2.20"
|
||||
path_provider_foundation:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: path_provider_foundation
|
||||
sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699"
|
||||
sha256: efaec349ddfc181528345c56f8eda9d6cccd71c177511b132c6a0ddaefaa2738
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.6.0"
|
||||
version: "2.4.3"
|
||||
path_provider_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -914,10 +930,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: rxdart
|
||||
sha256: "5c3004a4a8dbb94bd4bf5412a4def4acdaa12e12f269737a5751369e12d1a962"
|
||||
sha256: "0c7c0cedd93788d996e33041ffecda924cc54389199cde4e6a34b440f50044cb"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.28.0"
|
||||
version: "0.27.7"
|
||||
saf_stream:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -978,26 +994,26 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: shared_preferences
|
||||
sha256: "2939ae520c9024cb197fc20dee269cd8cdbf564c8b5746374ec6cacdc5169e64"
|
||||
sha256: "6e8bf70b7fef813df4e9a36f658ac46d107db4b4cfe1048b477d4e453a8159f5"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.5.4"
|
||||
version: "2.5.3"
|
||||
shared_preferences_android:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: shared_preferences_android
|
||||
sha256: cbc40be9be1c5af4dab4d6e0de4d5d3729e6f3d65b89d21e1815d57705644a6f
|
||||
sha256: "34266009473bf71d748912da4bf62d439185226c03e01e2d9687bc65bbfcb713"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.20"
|
||||
version: "2.4.15"
|
||||
shared_preferences_foundation:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: shared_preferences_foundation
|
||||
sha256: "4e7eaffc2b17ba398759f1151415869a34771ba11ebbccd1b0145472a619a64f"
|
||||
sha256: "1c33a907142607c40a7542768ec9badfd16293bac51da3a4482623d15845f88b"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.5.6"
|
||||
version: "2.5.5"
|
||||
shared_preferences_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -1055,50 +1071,50 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: slang
|
||||
sha256: "81e277dc5e2305f53412b92afeb803620453259afe147d5cd6417700f998a7a6"
|
||||
sha256: "13e3b6f07adc51ab751e7889647774d294cbce7a3382f81d9e5029acfe9c37b2"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.12.1"
|
||||
version: "4.12.0"
|
||||
slang_build_runner:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
name: slang_build_runner
|
||||
sha256: "010f337703dbc736ac9f0e3eb9d6e1464850736fed69f85a23c07ee4678fcc1c"
|
||||
sha256: "453d74b5430153a3c4150d5ba8f6380e0785f3939f7511f10ac5b6cf9bb7d2a7"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.12.1"
|
||||
version: "4.12.0"
|
||||
slang_flutter:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: slang_flutter
|
||||
sha256: "0f0276c400660c8b67150005aa4df57643b86ce6ae9c824abee8e25f345d9abc"
|
||||
sha256: "0a4545cca5404d6b7487cf61cf1fe56c52daeb08de56a7574ee8381fbad035a0"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.12.1"
|
||||
version: "4.12.0"
|
||||
source_gen:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: source_gen
|
||||
sha256: "1d562a3c1f713904ebbed50d2760217fd8a51ca170ac4b05b0db490699dbac17"
|
||||
sha256: "35c8150ece9e8c8d263337a265153c3329667640850b9304861faea59fc98f6b"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.2.0"
|
||||
version: "2.0.0"
|
||||
source_helper:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: source_helper
|
||||
sha256: "6a3c6cc82073a8797f8c4dc4572146114a39652851c157db37e964d9c7038723"
|
||||
sha256: a447acb083d3a5ef17f983dd36201aeea33fedadb3228fa831f2f0c92f0f3aca
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.3.8"
|
||||
version: "1.3.7"
|
||||
source_span:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: source_span
|
||||
sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab"
|
||||
sha256: "254ee5351d6cb365c859e20ee823c3bb479bf4a293c22d17a9f1bf144ce86f7c"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.10.2"
|
||||
version: "1.10.1"
|
||||
sqflite:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -1159,10 +1175,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: sqlparser
|
||||
sha256: "337e9997f7141ffdd054259128553c348635fa318f7ca492f07a4ab76f850d19"
|
||||
sha256: "57090342af1ce32bb499aa641f4ecdd2d6231b9403cea537ac059e803cc20d67"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.43.1"
|
||||
version: "0.41.2"
|
||||
stack_trace:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -1219,6 +1235,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.7.7"
|
||||
timing:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: timing
|
||||
sha256: "62ee18aca144e4a9f29d212f5a4c6a053be252b895ab14b5821996cff4ed90fe"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.2"
|
||||
typed_data:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -1247,34 +1271,34 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: url_launcher_android
|
||||
sha256: "767344bf3063897b5cf0db830e94f904528e6dd50a6dfaf839f0abf509009611"
|
||||
sha256: "5c8b6c2d89a78f5a1cca70a73d9d5f86c701b36b42f9c9dac7bad592113c28e9"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.3.28"
|
||||
version: "6.3.24"
|
||||
url_launcher_ios:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: url_launcher_ios
|
||||
sha256: cfde38aa257dae62ffe79c87fab20165dfdf6988c1d31b58ebf59b9106062aad
|
||||
sha256: "6b63f1441e4f653ae799166a72b50b1767321ecc263a57aadf825a7a2a5477d9"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.3.6"
|
||||
version: "6.3.5"
|
||||
url_launcher_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: url_launcher_linux
|
||||
sha256: d5e14138b3bc193a0f63c10a53c94b91d399df0512b1f29b94a043db7482384a
|
||||
sha256: "4e9ba368772369e3e08f231d2301b4ef72b9ff87c31192ef471b380ef29a4935"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.2.2"
|
||||
version: "3.2.1"
|
||||
url_launcher_macos:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: url_launcher_macos
|
||||
sha256: "368adf46f71ad3c21b8f06614adb38346f193f3a59ba8fe9a2fd74133070ba18"
|
||||
sha256: "8262208506252a3ed4ff5c0dc1e973d2c0e0ef337d0a074d35634da5d44397c9"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.2.5"
|
||||
version: "3.2.4"
|
||||
url_launcher_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -1287,18 +1311,18 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: url_launcher_web
|
||||
sha256: d0412fcf4c6b31ecfdb7762359b7206ffba3bbffd396c6d9f9c4616ece476c1f
|
||||
sha256: "4bd2b7b4dc4d4d0b94e5babfffbca8eac1a126c7f3d6ecbc1a11013faa3abba2"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.2"
|
||||
version: "2.4.1"
|
||||
url_launcher_windows:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: url_launcher_windows
|
||||
sha256: "712c70ab1b99744ff066053cbe3e80c73332b38d46e5e945c98689b2e66fc15f"
|
||||
sha256: "3284b6d2ac454cf34f114e1d3319866fdd1e19cdc329999057e44ffe936cfa77"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.5"
|
||||
version: "3.1.4"
|
||||
uuid:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -1367,10 +1391,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: watcher
|
||||
sha256: "1398c9f081a753f9226febe8900fce8f7d0a67163334e1c94a2438339d79d635"
|
||||
sha256: "592ab6e2892f67760543fb712ff0177f4ec76c031f02f5b4ff8d3fc5eb9fb61a"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.2.1"
|
||||
version: "1.1.4"
|
||||
web:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -1477,4 +1501,4 @@ packages:
|
||||
version: "3.1.3"
|
||||
sdks:
|
||||
dart: ">=3.10.7 <4.0.0"
|
||||
flutter: ">=3.38.4"
|
||||
flutter: ">=3.35.0"
|
||||
|
||||
@@ -48,6 +48,7 @@ dependencies:
|
||||
in_app_review: ^2.0.11
|
||||
dart_discord_presence: ^1.1.0
|
||||
flutter_svg: ^2.2.3
|
||||
mobile_scanner: ^6.0.2
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
|
||||
Reference in New Issue
Block a user