Migrate companion remote models to @JsonSerializable

This commit is contained in:
Matt Vogel
2026-02-09 14:26:51 -05:00
parent 972ded5b0c
commit 199d634cb2
18 changed files with 1540 additions and 819 deletions
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -6,7 +6,7 @@
/// Locales: 9
/// Strings: 5121 (569 per locale)
///
/// Built on 2026-02-08 at 11:03 UTC
/// Built on 2026-02-09 at 18:36 UTC
// coverage:ignore-file
// ignore_for_file: type=lint, unused_import
@@ -0,0 +1,44 @@
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;
RecentRemoteSession({
required this.sessionId,
required this.pin,
required this.deviceName,
required this.platform,
required this.lastConnected,
});
factory RecentRemoteSession.fromJson(Map<String, dynamic> json) => _$RecentRemoteSessionFromJson(json);
Map<String, dynamic> toJson() => _$RecentRemoteSessionToJson(this);
/// Create from QR code data (format: "sessionId:pin:deviceName:platform")
factory RecentRemoteSession.fromQrData(String qrData) {
final parts = qrData.split(':');
if (parts.length < 2) {
throw FormatException('Invalid QR code format');
}
return RecentRemoteSession(
sessionId: parts[0],
pin: parts[1],
deviceName: parts.length > 2 ? parts[2] : 'Unknown Device',
platform: parts.length > 3 ? parts[3] : 'unknown',
lastConnected: DateTime.now(),
);
}
@override
String toString() => '$deviceName ($platform) - Last: ${lastConnected.toLocal()}';
}
@@ -0,0 +1,23 @@
// 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),
);
Map<String, dynamic> _$RecentRemoteSessionToJson(RecentRemoteSession instance) => <String, dynamic>{
'sessionId': instance.sessionId,
'pin': instance.pin,
'deviceName': instance.deviceName,
'platform': instance.platform,
'lastConnected': instance.lastConnected.toIso8601String(),
};
+11 -32
View File
@@ -1,42 +1,24 @@
import 'package:json_annotation/json_annotation.dart';
import 'remote_command_type.dart';
part 'remote_command.g.dart';
@JsonSerializable()
class RemoteCommand {
@JsonKey(unknownEnumValue: RemoteCommandType.ping)
final RemoteCommandType type;
final String deviceId;
final String deviceName;
final DateTime timestamp;
final Map<String, dynamic>? data;
RemoteCommand({
required this.type,
required this.deviceId,
required this.deviceName,
DateTime? timestamp,
this.data,
}) : timestamp = timestamp ?? DateTime.now();
RemoteCommand({required this.type, required this.deviceId, required this.deviceName, DateTime? timestamp, this.data})
: timestamp = timestamp ?? DateTime.now();
Map<String, dynamic> toJson() {
return {
'type': type.name,
'deviceId': deviceId,
'deviceName': deviceName,
'timestamp': timestamp.toIso8601String(),
if (data != null) 'data': data,
};
}
factory RemoteCommand.fromJson(Map<String, dynamic> json) => _$RemoteCommandFromJson(json);
factory RemoteCommand.fromJson(Map<String, dynamic> json) {
return RemoteCommand(
type: RemoteCommandType.values.firstWhere(
(e) => e.name == json['type'],
orElse: () => RemoteCommandType.ping,
),
deviceId: json['deviceId'] as String,
deviceName: json['deviceName'] as String,
timestamp: DateTime.parse(json['timestamp'] as String),
data: json['data'] as Map<String, dynamic>?,
);
}
Map<String, dynamic> toJson() => _$RemoteCommandToJson(this);
RemoteCommand copyWith({
RemoteCommandType? type,
@@ -72,9 +54,6 @@ class RemoteCommand {
@override
int get hashCode {
return type.hashCode ^
deviceId.hashCode ^
deviceName.hashCode ^
timestamp.hashCode;
return type.hashCode ^ deviceId.hashCode ^ deviceName.hashCode ^ timestamp.hashCode;
}
}
@@ -0,0 +1,67 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'remote_command.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
RemoteCommand _$RemoteCommandFromJson(Map<String, dynamic> json) => RemoteCommand(
type: $enumDecode(_$RemoteCommandTypeEnumMap, json['type'], unknownValue: RemoteCommandType.ping),
deviceId: json['deviceId'] as String,
deviceName: json['deviceName'] as String,
timestamp: json['timestamp'] == null ? null : DateTime.parse(json['timestamp'] as String),
data: json['data'] as Map<String, dynamic>?,
);
Map<String, dynamic> _$RemoteCommandToJson(RemoteCommand instance) => <String, dynamic>{
'type': _$RemoteCommandTypeEnumMap[instance.type]!,
'deviceId': instance.deviceId,
'deviceName': instance.deviceName,
'timestamp': instance.timestamp.toIso8601String(),
'data': instance.data,
};
const _$RemoteCommandTypeEnumMap = {
RemoteCommandType.dpadUp: 'dpadUp',
RemoteCommandType.dpadDown: 'dpadDown',
RemoteCommandType.dpadLeft: 'dpadLeft',
RemoteCommandType.dpadRight: 'dpadRight',
RemoteCommandType.select: 'select',
RemoteCommandType.back: 'back',
RemoteCommandType.contextMenu: 'contextMenu',
RemoteCommandType.play: 'play',
RemoteCommandType.pause: 'pause',
RemoteCommandType.playPause: 'playPause',
RemoteCommandType.stop: 'stop',
RemoteCommandType.seekForward: 'seekForward',
RemoteCommandType.seekBackward: 'seekBackward',
RemoteCommandType.nextTrack: 'nextTrack',
RemoteCommandType.previousTrack: 'previousTrack',
RemoteCommandType.skipIntro: 'skipIntro',
RemoteCommandType.skipCredits: 'skipCredits',
RemoteCommandType.volumeUp: 'volumeUp',
RemoteCommandType.volumeDown: 'volumeDown',
RemoteCommandType.volumeMute: 'volumeMute',
RemoteCommandType.volumeSet: 'volumeSet',
RemoteCommandType.tabNext: 'tabNext',
RemoteCommandType.tabPrevious: 'tabPrevious',
RemoteCommandType.tabDiscover: 'tabDiscover',
RemoteCommandType.tabLibraries: 'tabLibraries',
RemoteCommandType.tabSearch: 'tabSearch',
RemoteCommandType.tabDownloads: 'tabDownloads',
RemoteCommandType.tabSettings: 'tabSettings',
RemoteCommandType.home: 'home',
RemoteCommandType.search: 'search',
RemoteCommandType.subtitles: 'subtitles',
RemoteCommandType.audioTracks: 'audioTracks',
RemoteCommandType.qualitySettings: 'qualitySettings',
RemoteCommandType.fullscreen: 'fullscreen',
RemoteCommandType.ping: 'ping',
RemoteCommandType.pong: 'pong',
RemoteCommandType.deviceInfo: 'deviceInfo',
RemoteCommandType.capabilitiesRequest: 'capabilitiesRequest',
RemoteCommandType.capabilitiesResponse: 'capabilitiesResponse',
RemoteCommandType.disconnect: 'disconnect',
RemoteCommandType.ack: 'ack',
};
+18 -63
View File
@@ -1,16 +1,12 @@
enum RemoteSessionRole {
host,
remote,
}
import 'package:json_annotation/json_annotation.dart';
enum RemoteSessionStatus {
disconnected,
connecting,
connected,
reconnecting,
error,
}
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;
@@ -24,28 +20,12 @@ class RemoteDevice {
required this.platform,
DateTime? connectedAt,
Map<String, bool>? capabilities,
}) : connectedAt = connectedAt ?? DateTime.now(),
capabilities = capabilities ?? {};
}) : connectedAt = connectedAt ?? DateTime.now(),
capabilities = capabilities ?? {};
Map<String, dynamic> toJson() {
return {
'id': id,
'name': name,
'platform': platform,
'connectedAt': connectedAt.toIso8601String(),
'capabilities': capabilities,
};
}
factory RemoteDevice.fromJson(Map<String, dynamic> json) => _$RemoteDeviceFromJson(json);
factory RemoteDevice.fromJson(Map<String, dynamic> json) {
return RemoteDevice(
id: json['id'] as String,
name: json['name'] as String,
platform: json['platform'] as String,
connectedAt: DateTime.parse(json['connectedAt'] as String),
capabilities: Map<String, bool>.from(json['capabilities'] as Map? ?? {}),
);
}
Map<String, dynamic> toJson() => _$RemoteDeviceToJson(this);
RemoteDevice copyWith({
String? id,
@@ -74,10 +54,13 @@ class RemoteDevice {
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;
@@ -97,6 +80,10 @@ class RemoteSession {
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,
@@ -116,36 +103,4 @@ class RemoteSession {
errorMessage: errorMessage ?? this.errorMessage,
);
}
Map<String, dynamic> toJson() {
return {
'sessionId': sessionId,
'pin': pin,
'role': role.name,
'status': status.name,
'connectedDevice': connectedDevice?.toJson(),
'createdAt': createdAt.toIso8601String(),
'errorMessage': errorMessage,
};
}
factory RemoteSession.fromJson(Map<String, dynamic> json) {
return RemoteSession(
sessionId: json['sessionId'] as String,
pin: json['pin'] as String,
role: RemoteSessionRole.values.firstWhere(
(e) => e.name == json['role'],
orElse: () => RemoteSessionRole.remote,
),
status: RemoteSessionStatus.values.firstWhere(
(e) => e.name == json['status'],
orElse: () => RemoteSessionStatus.disconnected,
),
connectedDevice: json['connectedDevice'] != null
? RemoteDevice.fromJson(json['connectedDevice'] as Map<String, dynamic>)
: null,
createdAt: DateTime.parse(json['createdAt'] as String),
errorMessage: json['errorMessage'] as String?,
);
}
}
@@ -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',
};
@@ -1,3 +1,8 @@
import 'package:json_annotation/json_annotation.dart';
part 'trusted_device.g.dart';
@JsonSerializable()
class TrustedDevice {
final String peerId;
final String deviceName;
@@ -13,30 +18,12 @@ class TrustedDevice {
DateTime? firstConnected,
DateTime? lastConnected,
this.isApproved = false,
}) : firstConnected = firstConnected ?? DateTime.now(),
lastConnected = lastConnected ?? DateTime.now();
}) : firstConnected = firstConnected ?? DateTime.now(),
lastConnected = lastConnected ?? DateTime.now();
Map<String, dynamic> toJson() {
return {
'peerId': peerId,
'deviceName': deviceName,
'platform': platform,
'firstConnected': firstConnected.toIso8601String(),
'lastConnected': lastConnected.toIso8601String(),
'isApproved': isApproved,
};
}
factory TrustedDevice.fromJson(Map<String, dynamic> json) => _$TrustedDeviceFromJson(json);
factory TrustedDevice.fromJson(Map<String, dynamic> json) {
return TrustedDevice(
peerId: json['peerId'] as String,
deviceName: json['deviceName'] as String,
platform: json['platform'] as String,
firstConnected: DateTime.parse(json['firstConnected'] as String),
lastConnected: DateTime.parse(json['lastConnected'] as String),
isApproved: json['isApproved'] as bool? ?? false,
);
}
Map<String, dynamic> toJson() => _$TrustedDeviceToJson(this);
TrustedDevice copyWith({
String? peerId,
@@ -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,
};
+20 -12
View File
@@ -6,15 +6,23 @@ part of 'play_queue_response.dart';
// JsonSerializableGenerator
// **************************************************************************
PlayQueueResponse _$PlayQueueResponseFromJson(Map<String, dynamic> json) => PlayQueueResponse(
playQueueID: (json['playQueueID'] as num).toInt(),
playQueueSelectedItemID: (json['playQueueSelectedItemID'] as num?)?.toInt(),
playQueueSelectedItemOffset: (json['playQueueSelectedItemOffset'] as num?)?.toInt(),
playQueueSelectedMetadataItemID: json['playQueueSelectedMetadataItemID'] as String?,
playQueueShuffled: const BoolOrIntConverter().fromJson(json['playQueueShuffled'] as Object),
playQueueSourceURI: json['playQueueSourceURI'] as String?,
playQueueTotalCount: (json['playQueueTotalCount'] as num?)?.toInt(),
playQueueVersion: (json['playQueueVersion'] as num).toInt(),
size: (json['size'] as num?)?.toInt(),
items: (json['Metadata'] as List<dynamic>?)?.map((e) => PlexMetadata.fromJson(e as Map<String, dynamic>)).toList(),
);
PlayQueueResponse _$PlayQueueResponseFromJson(Map<String, dynamic> json) =>
PlayQueueResponse(
playQueueID: (json['playQueueID'] as num).toInt(),
playQueueSelectedItemID: (json['playQueueSelectedItemID'] as num?)
?.toInt(),
playQueueSelectedItemOffset: (json['playQueueSelectedItemOffset'] as num?)
?.toInt(),
playQueueSelectedMetadataItemID:
json['playQueueSelectedMetadataItemID'] as String?,
playQueueShuffled: const BoolOrIntConverter().fromJson(
json['playQueueShuffled'] as Object,
),
playQueueSourceURI: json['playQueueSourceURI'] as String?,
playQueueTotalCount: (json['playQueueTotalCount'] as num?)?.toInt(),
playQueueVersion: (json['playQueueVersion'] as num).toInt(),
size: (json['size'] as num?)?.toInt(),
items: (json['Metadata'] as List<dynamic>?)
?.map((e) => PlexMetadata.fromJson(e as Map<String, dynamic>))
.toList(),
);
+13 -12
View File
@@ -19,15 +19,16 @@ PlexLibrary _$PlexLibraryFromJson(Map<String, dynamic> json) => PlexLibrary(
hidden: (json['hidden'] as num?)?.toInt(),
);
Map<String, dynamic> _$PlexLibraryToJson(PlexLibrary instance) => <String, dynamic>{
'key': instance.key,
'title': instance.title,
'type': instance.type,
'agent': instance.agent,
'scanner': instance.scanner,
'language': instance.language,
'uuid': instance.uuid,
'updatedAt': instance.updatedAt,
'createdAt': instance.createdAt,
'hidden': instance.hidden,
};
Map<String, dynamic> _$PlexLibraryToJson(PlexLibrary instance) =>
<String, dynamic>{
'key': instance.key,
'title': instance.title,
'type': instance.type,
'agent': instance.agent,
'scanner': instance.scanner,
'language': instance.language,
'uuid': instance.uuid,
'updatedAt': instance.updatedAt,
'createdAt': instance.createdAt,
'hidden': instance.hidden,
};
+49 -46
View File
@@ -41,7 +41,9 @@ PlexMetadata _$PlexMetadataFromJson(Map<String, dynamic> json) => PlexMetadata(
leafCount: (json['leafCount'] as num?)?.toInt(),
viewedLeafCount: (json['viewedLeafCount'] as num?)?.toInt(),
childCount: (json['childCount'] as num?)?.toInt(),
role: (json['Role'] as List<dynamic>?)?.map((e) => PlexRole.fromJson(e as Map<String, dynamic>)).toList(),
role: (json['Role'] as List<dynamic>?)
?.map((e) => PlexRole.fromJson(e as Map<String, dynamic>))
.toList(),
audioLanguage: json['audioLanguage'] as String?,
subtitleLanguage: json['subtitleLanguage'] as String?,
playlistItemID: (json['playlistItemID'] as num?)?.toInt(),
@@ -52,48 +54,49 @@ PlexMetadata _$PlexMetadataFromJson(Map<String, dynamic> json) => PlexMetadata(
clearLogo: json['clearLogo'] as String?,
);
Map<String, dynamic> _$PlexMetadataToJson(PlexMetadata instance) => <String, dynamic>{
'ratingKey': instance.ratingKey,
'key': instance.key,
'guid': instance.guid,
'studio': instance.studio,
'type': instance.type,
'title': instance.title,
'titleSort': instance.titleSort,
'contentRating': instance.contentRating,
'summary': instance.summary,
'rating': instance.rating,
'audienceRating': instance.audienceRating,
'year': instance.year,
'originallyAvailableAt': instance.originallyAvailableAt,
'thumb': instance.thumb,
'art': instance.art,
'duration': instance.duration,
'addedAt': instance.addedAt,
'updatedAt': instance.updatedAt,
'lastViewedAt': instance.lastViewedAt,
'grandparentTitle': instance.grandparentTitle,
'grandparentThumb': instance.grandparentThumb,
'grandparentArt': instance.grandparentArt,
'grandparentRatingKey': instance.grandparentRatingKey,
'parentTitle': instance.parentTitle,
'parentThumb': instance.parentThumb,
'parentRatingKey': instance.parentRatingKey,
'parentIndex': instance.parentIndex,
'index': instance.index,
'grandparentTheme': instance.grandparentTheme,
'viewOffset': instance.viewOffset,
'viewCount': instance.viewCount,
'leafCount': instance.leafCount,
'viewedLeafCount': instance.viewedLeafCount,
'childCount': instance.childCount,
'Role': instance.role,
'audioLanguage': instance.audioLanguage,
'subtitleLanguage': instance.subtitleLanguage,
'playlistItemID': instance.playlistItemID,
'playQueueItemID': instance.playQueueItemID,
'librarySectionID': instance.librarySectionID,
'ratingImage': instance.ratingImage,
'audienceRatingImage': instance.audienceRatingImage,
'clearLogo': instance.clearLogo,
};
Map<String, dynamic> _$PlexMetadataToJson(PlexMetadata instance) =>
<String, dynamic>{
'ratingKey': instance.ratingKey,
'key': instance.key,
'guid': instance.guid,
'studio': instance.studio,
'type': instance.type,
'title': instance.title,
'titleSort': instance.titleSort,
'contentRating': instance.contentRating,
'summary': instance.summary,
'rating': instance.rating,
'audienceRating': instance.audienceRating,
'year': instance.year,
'originallyAvailableAt': instance.originallyAvailableAt,
'thumb': instance.thumb,
'art': instance.art,
'duration': instance.duration,
'addedAt': instance.addedAt,
'updatedAt': instance.updatedAt,
'lastViewedAt': instance.lastViewedAt,
'grandparentTitle': instance.grandparentTitle,
'grandparentThumb': instance.grandparentThumb,
'grandparentArt': instance.grandparentArt,
'grandparentRatingKey': instance.grandparentRatingKey,
'parentTitle': instance.parentTitle,
'parentThumb': instance.parentThumb,
'parentRatingKey': instance.parentRatingKey,
'parentIndex': instance.parentIndex,
'index': instance.index,
'grandparentTheme': instance.grandparentTheme,
'viewOffset': instance.viewOffset,
'viewCount': instance.viewCount,
'leafCount': instance.leafCount,
'viewedLeafCount': instance.viewedLeafCount,
'childCount': instance.childCount,
'Role': instance.role,
'audioLanguage': instance.audioLanguage,
'subtitleLanguage': instance.subtitleLanguage,
'playlistItemID': instance.playlistItemID,
'playQueueItemID': instance.playQueueItemID,
'librarySectionID': instance.librarySectionID,
'ratingImage': instance.ratingImage,
'audienceRatingImage': instance.audienceRatingImage,
'clearLogo': instance.clearLogo,
};
+20 -19
View File
@@ -26,22 +26,23 @@ PlexPlaylist _$PlexPlaylistFromJson(Map<String, dynamic> json) => PlexPlaylist(
thumb: json['thumb'] as String?,
);
Map<String, dynamic> _$PlexPlaylistToJson(PlexPlaylist instance) => <String, dynamic>{
'ratingKey': instance.ratingKey,
'key': instance.key,
'type': instance.type,
'title': instance.title,
'summary': instance.summary,
'smart': instance.smart,
'playlistType': instance.playlistType,
'duration': instance.duration,
'leafCount': instance.leafCount,
'composite': instance.composite,
'addedAt': instance.addedAt,
'updatedAt': instance.updatedAt,
'lastViewedAt': instance.lastViewedAt,
'viewCount': instance.viewCount,
'content': instance.content,
'guid': instance.guid,
'thumb': instance.thumb,
};
Map<String, dynamic> _$PlexPlaylistToJson(PlexPlaylist instance) =>
<String, dynamic>{
'ratingKey': instance.ratingKey,
'key': instance.key,
'type': instance.type,
'title': instance.title,
'summary': instance.summary,
'smart': instance.smart,
'playlistType': instance.playlistType,
'duration': instance.duration,
'leafCount': instance.leafCount,
'composite': instance.composite,
'addedAt': instance.addedAt,
'updatedAt': instance.updatedAt,
'lastViewedAt': instance.lastViewedAt,
'viewCount': instance.viewCount,
'content': instance.content,
'guid': instance.guid,
'thumb': instance.thumb,
};
+28 -55
View File
@@ -10,6 +10,7 @@ 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';
@@ -99,28 +100,28 @@ class CompanionRemoteProvider with ChangeNotifier {
}
void _setupPeerServiceListeners() {
_commandSubscription = _peerService!.onCommandReceived.listen((command) {
appLogger.d('CompanionRemote: Command received: ${command.type}');
_commandSubscription = _peerService!.onCommandReceived.listen(
(command) {
appLogger.d('CompanionRemote: Command received: ${command.type}');
if (command.type == RemoteCommandType.deviceInfo) {
_handleDeviceInfo(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);
});
if (command.type == RemoteCommandType.deviceInfo) {
_handleDeviceInfo(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,
);
_session = _session?.copyWith(status: RemoteSessionStatus.connected, connectedDevice: device);
notifyListeners();
await addTrustedDevice(device, requireApproval: isHost);
@@ -129,15 +130,10 @@ class CompanionRemoteProvider with ChangeNotifier {
_deviceDisconnectedSubscription = _peerService!.onDeviceDisconnected.listen((_) {
appLogger.d('CompanionRemote: Device disconnected (intentional: $_intentionalDisconnect)');
if (_intentionalDisconnect) {
_session = _session?.copyWith(
status: RemoteSessionStatus.disconnected,
connectedDevice: null,
);
_session = _session?.copyWith(status: RemoteSessionStatus.disconnected, connectedDevice: null);
notifyListeners();
} else {
_session = _session?.copyWith(
status: RemoteSessionStatus.reconnecting,
);
_session = _session?.copyWith(status: RemoteSessionStatus.reconnecting);
notifyListeners();
_scheduleReconnect();
}
@@ -145,10 +141,7 @@ class CompanionRemoteProvider with ChangeNotifier {
_errorSubscription = _peerService!.onError.listen((error) {
appLogger.e('CompanionRemote: Error: ${error.message}');
_session = _session?.copyWith(
status: RemoteSessionStatus.error,
errorMessage: error.message,
);
_session = _session?.copyWith(status: RemoteSessionStatus.error, errorMessage: error.message);
notifyListeners();
});
@@ -166,11 +159,7 @@ class CompanionRemoteProvider with ChangeNotifier {
appLogger.d('CompanionRemote: Device info - name: ${command.deviceName}, platform: $platform, role: $role');
final device = RemoteDevice(
id: command.deviceId,
name: command.deviceName,
platform: platform,
);
final device = RemoteDevice(id: command.deviceId, name: command.deviceName, platform: platform);
_session = _session?.copyWith(connectedDevice: device);
notifyListeners();
@@ -256,10 +245,7 @@ class CompanionRemoteProvider with ChangeNotifier {
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(),
);
_session = _session?.copyWith(status: RemoteSessionStatus.error, errorMessage: e.toString());
notifyListeners();
rethrow;
}
@@ -305,10 +291,7 @@ class CompanionRemoteProvider with ChangeNotifier {
Future<void> _attemptReconnect() async {
if (_lastSessionId == null || _lastPin == null) {
appLogger.w('CompanionRemote: No stored credentials for reconnect');
_session = _session?.copyWith(
status: RemoteSessionStatus.error,
errorMessage: 'Connection lost',
);
_session = _session?.copyWith(status: RemoteSessionStatus.error, errorMessage: 'Connection lost');
notifyListeners();
return;
}
@@ -347,10 +330,7 @@ class CompanionRemoteProvider with ChangeNotifier {
void cancelReconnect() {
_reconnectTimer?.cancel();
_reconnectAttempts = 0;
_session = _session?.copyWith(
status: RemoteSessionStatus.disconnected,
connectedDevice: null,
);
_session = _session?.copyWith(status: RemoteSessionStatus.disconnected, connectedDevice: null);
notifyListeners();
}
@@ -379,9 +359,7 @@ class CompanionRemoteProvider with ChangeNotifier {
if (json != null) {
final List<dynamic> list = jsonDecode(json);
_trustedDevices.clear();
_trustedDevices.addAll(
list.map((e) => TrustedDevice.fromJson(e as Map<String, dynamic>)),
);
_trustedDevices.addAll(list.map((e) => TrustedDevice.fromJson(e as Map<String, dynamic>)));
appLogger.d('CompanionRemote: Loaded ${_trustedDevices.length} trusted devices');
}
} catch (e) {
@@ -424,12 +402,7 @@ class CompanionRemoteProvider with ChangeNotifier {
}
_trustedDevices.add(
TrustedDevice(
peerId: device.id,
deviceName: device.name,
platform: device.platform,
isApproved: approved,
),
TrustedDevice(peerId: device.id, deviceName: device.name, platform: device.platform, isApproved: approved),
);
}
@@ -6,7 +6,7 @@ import 'package:mobile_scanner/mobile_scanner.dart';
import 'package:provider/provider.dart';
import '../../providers/companion_remote_provider.dart';
import '../../services/companion_remote/companion_remote_discovery_service.dart';
import '../../models/companion_remote/recent_remote_session.dart';
import '../../utils/app_logger.dart';
class PairingScreen extends StatefulWidget {
@@ -105,10 +105,7 @@ class _PairingScreenState extends State<PairingScreen> {
try {
final provider = context.read<CompanionRemoteProvider>();
await provider.joinSession(
_sessionIdController.text.trim().toUpperCase(),
_pinController.text.trim(),
);
await provider.joinSession(_sessionIdController.text.trim().toUpperCase(), _pinController.text.trim());
if (mounted) {
Navigator.of(context).pop();
@@ -198,22 +195,10 @@ class _PairingScreenState extends State<PairingScreen> {
children: [
SegmentedButton<int>(
segments: [
const ButtonSegment(
value: 0,
label: Text('Recent'),
icon: Icon(Icons.history),
),
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),
),
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) {
@@ -222,9 +207,7 @@ class _PairingScreenState extends State<PairingScreen> {
});
},
),
Expanded(
child: _buildTabContent(),
),
Expanded(child: _buildTabContent()),
],
),
);
@@ -344,27 +327,16 @@ class _PairingScreenState extends State<PairingScreen> {
if (_isDiscovering) ...[
const Center(child: CircularProgressIndicator()),
const SizedBox(height: 16),
Text(
'Loading...',
style: Theme.of(context).textTheme.bodyMedium,
textAlign: TextAlign.center,
),
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,
),
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,
),
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',
@@ -390,11 +362,7 @@ class _PairingScreenState extends State<PairingScreen> {
),
isThreeLine: true,
trailing: isThisConnecting
? const SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(strokeWidth: 2),
)
? const SizedBox(width: 24, height: 24, child: CircularProgressIndicator(strokeWidth: 2))
: const Icon(Icons.arrow_forward),
onTap: _isConnecting ? null : () => _connectToRecentSession(session),
onLongPress: () => _showRemoveSessionDialog(session),
@@ -410,17 +378,12 @@ class _PairingScreenState extends State<PairingScreen> {
padding: const EdgeInsets.all(16.0),
child: Row(
children: [
Icon(
Icons.error_outline,
color: Theme.of(context).colorScheme.onErrorContainer,
),
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,
),
style: TextStyle(color: Theme.of(context).colorScheme.onErrorContainer),
),
),
],
@@ -459,14 +422,8 @@ class _PairingScreenState extends State<PairingScreen> {
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'),
),
TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('Cancel')),
TextButton(onPressed: () => Navigator.pop(context, true), child: const Text('Remove')),
],
),
);
@@ -486,11 +443,7 @@ class _PairingScreenState extends State<PairingScreen> {
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,
),
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',
@@ -505,17 +458,12 @@ class _PairingScreenState extends State<PairingScreen> {
padding: const EdgeInsets.all(16.0),
child: Row(
children: [
Icon(
Icons.error_outline,
color: Theme.of(context).colorScheme.onErrorContainer,
),
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,
),
style: TextStyle(color: Theme.of(context).colorScheme.onErrorContainer),
),
),
],
@@ -571,10 +519,7 @@ class _PairingScreenState extends State<PairingScreen> {
),
),
keyboardType: TextInputType.number,
inputFormatters: [
FilteringTextInputFormatter.digitsOnly,
LengthLimitingTextInputFormatter(6),
],
inputFormatters: [FilteringTextInputFormatter.digitsOnly, LengthLimitingTextInputFormatter(6)],
validator: (value) {
if (value == null || value.isEmpty) {
return 'Please enter a PIN';
@@ -590,21 +535,14 @@ class _PairingScreenState extends State<PairingScreen> {
FilledButton.icon(
onPressed: _isConnecting ? null : _connect,
icon: _isConnecting
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
? 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,
),
Text('Tips', style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 8),
_buildTipCard(
context,
@@ -620,15 +558,11 @@ class _PairingScreenState extends State<PairingScreen> {
),
],
const SizedBox(height: 8),
_buildTipCard(
context,
Icons.wifi,
'Make sure both devices are on the same WiFi network',
),
],
),
_buildTipCard(context, Icons.wifi, 'Make sure both devices are on the same WiFi network'),
],
),
);
),
);
}
Widget _buildTipCard(BuildContext context, IconData icon, String text) {
@@ -639,12 +573,7 @@ class _PairingScreenState extends State<PairingScreen> {
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,
),
),
Expanded(child: Text(text, style: Theme.of(context).textTheme.bodySmall)),
],
),
),
@@ -1,65 +1,10 @@
import 'dart:async';
import 'dart:convert';
import '../../models/companion_remote/recent_remote_session.dart';
import '../../services/storage_service.dart';
import '../../utils/app_logger.dart';
/// Recent Companion Remote session for quick reconnection
class RecentRemoteSession {
final String sessionId;
final String pin;
final String deviceName;
final String platform;
final DateTime lastConnected;
RecentRemoteSession({
required this.sessionId,
required this.pin,
required this.deviceName,
required this.platform,
required this.lastConnected,
});
factory RecentRemoteSession.fromJson(Map<String, dynamic> json) {
return 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),
);
}
Map<String, dynamic> toJson() {
return {
'sessionId': sessionId,
'pin': pin,
'deviceName': deviceName,
'platform': platform,
'lastConnected': lastConnected.toIso8601String(),
};
}
/// Create from QR code data (format: "sessionId:pin:deviceName:platform")
factory RecentRemoteSession.fromQrData(String qrData) {
final parts = qrData.split(':');
if (parts.length < 2) {
throw FormatException('Invalid QR code format');
}
return RecentRemoteSession(
sessionId: parts[0],
pin: parts[1],
deviceName: parts.length > 2 ? parts[2] : 'Unknown Device',
platform: parts.length > 3 ? parts[3] : 'unknown',
lastConnected: DateTime.now(),
);
}
@override
String toString() => '$deviceName ($platform) - Last: ${lastConnected.toLocal()}';
}
/// Service for managing recent Companion Remote sessions
class CompanionRemoteDiscoveryService {
static const String _storageKey = 'companion_remote_recent_sessions';
@@ -87,9 +32,7 @@ class CompanionRemoteDiscoveryService {
if (json != null) {
final List<dynamic> list = jsonDecode(json);
_recentSessions.clear();
_recentSessions.addAll(
list.map((e) => RecentRemoteSession.fromJson(e as Map<String, dynamic>)),
);
_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));
+2 -2
View File
@@ -60,7 +60,7 @@ PODS:
- wakelock_plus (0.0.1):
- FlutterMacOS
- WebRTC-SDK (137.7151.04)
- window_manager (0.2.0):
- window_manager (0.5.0):
- FlutterMacOS
DEPENDENCIES:
@@ -146,7 +146,7 @@ SPEC CHECKSUMS:
url_launcher_macos: f87a979182d112f911de6820aefddaf56ee9fbfd
wakelock_plus: 917609be14d812ddd9e9528876538b2263aaa03b
WebRTC-SDK: 40d4f5ba05cadff14e4db5614aec402a633f007e
window_manager: 1d01fa7ac65a6e6f83b965471b1a7fdd3f06166c
window_manager: b729e31d38fb04905235df9ea896128991cad99e
PODFILE CHECKSUM: d16f5e5d196d1ca9863d7a71d5885cad7ffa7d2d