fix(livetv): show recording indicators in guide

close #1015
This commit is contained in:
edde746
2026-05-12 06:53:52 +02:00
parent 5139e39ed4
commit 3ddd89113c
2 changed files with 157 additions and 6 deletions
@@ -30,6 +30,7 @@ void showProgramDetailsSheet(
required String? posterUrl,
required VoidCallback? onTuneChannel,
MediaServerClient? client,
ValueChanged<bool>? onRecordingStateChanged,
}) {
OverlaySheetController.showAdaptive(
context,
@@ -40,6 +41,7 @@ void showProgramDetailsSheet(
posterUrl: posterUrl,
onTuneChannel: onTuneChannel,
client: client,
onRecordingStateChanged: onRecordingStateChanged,
);
},
);
@@ -67,6 +69,7 @@ class _ProgramDetailsSheetContent extends StatefulWidget {
final String? posterUrl;
final VoidCallback? onTuneChannel;
final MediaServerClient? client;
final ValueChanged<bool>? onRecordingStateChanged;
const _ProgramDetailsSheetContent({
required this.program,
@@ -74,6 +77,7 @@ class _ProgramDetailsSheetContent extends StatefulWidget {
required this.posterUrl,
required this.onTuneChannel,
required this.client,
required this.onRecordingStateChanged,
});
@override
@@ -181,7 +185,10 @@ class _ProgramDetailsSheetContentState extends State<_ProgramDetailsSheetContent
style: _ActionStyle.filled,
onPressed: () async {
final deleted = await confirmDeleteRule(context, client, existing);
if (deleted && mounted) _closeSheet();
if (deleted && mounted) {
widget.onRecordingStateChanged?.call(false);
_closeSheet();
}
},
),
);
@@ -195,6 +202,7 @@ class _ProgramDetailsSheetContentState extends State<_ProgramDetailsSheetContent
final outcome = await recordProgram(context, client, program);
if (!mounted) return;
if (outcome == RecordOutcome.scheduled || outcome == RecordOutcome.alreadyScheduled) {
widget.onRecordingStateChanged?.call(true);
_closeSheet();
}
},
+148 -5
View File
@@ -13,6 +13,7 @@ import '../../../i18n/strings.g.dart';
import '../../../mixins/mounted_set_state_mixin.dart';
import '../../../models/livetv_channel.dart';
import '../../../models/livetv_program.dart';
import '../../../models/media_grab_operation.dart';
import '../../../providers/multi_server_provider.dart';
import '../../../media/media_server_client.dart';
import '../../../utils/app_logger.dart';
@@ -54,6 +55,7 @@ class GuideTabState extends State<GuideTab> with MountedSetStateMixin {
static const _minutesPerSlot = 30;
List<LiveTvProgram> _programs = [];
Set<String> _scheduledRecordingKeys = const {};
bool _isLoading = true;
late DateTime _gridStart;
@@ -125,6 +127,7 @@ class GuideTabState extends State<GuideTab> with MountedSetStateMixin {
// ignore: no-empty-block - setState triggers rebuild to update time indicator
setStateIfMounted(() {});
});
unawaited(_refreshScheduledRecordingKeys());
}
@override
@@ -204,6 +207,7 @@ class GuideTabState extends State<GuideTab> with MountedSetStateMixin {
final multiServer = context.read<MultiServerProvider>();
final liveTvServers = multiServer.liveTvServers;
final allPrograms = <LiveTvProgram>[];
final scheduledRecordingKeys = <String>{};
final queriedServers = <String>{};
for (final serverInfo in liveTvServers) {
@@ -219,6 +223,11 @@ class GuideTabState extends State<GuideTab> with MountedSetStateMixin {
final toDt = DateTime.fromMillisecondsSinceEpoch(endEpoch * 1000, isUtc: true);
final programs = await genericClient.liveTv.fetchSchedule(from: fromDt, to: toDt);
allPrograms.addAll(programs);
await _addScheduledRecordingKeysForServer(
client: genericClient,
serverId: serverInfo.serverId,
keys: scheduledRecordingKeys,
);
} catch (e) {
appLogger.e('Failed to load programs from server ${serverInfo.serverId}', error: e);
}
@@ -230,6 +239,7 @@ class GuideTabState extends State<GuideTab> with MountedSetStateMixin {
setState(() {
_programs = allPrograms;
_scheduledRecordingKeys = scheduledRecordingKeys;
_isLoading = false;
});
@@ -248,6 +258,102 @@ class GuideTabState extends State<GuideTab> with MountedSetStateMixin {
}
}
Future<void> _refreshScheduledRecordingKeys() async {
if (!mounted) return;
final multiServer = context.read<MultiServerProvider>();
final scheduledRecordingKeys = <String>{};
final queriedServers = <String>{};
for (final serverInfo in multiServer.liveTvServers) {
if (!queriedServers.add(serverInfo.serverId)) continue;
final client = multiServer.getClientForServer(serverInfo.serverId);
if (client == null) continue;
await _addScheduledRecordingKeysForServer(
client: client,
serverId: serverInfo.serverId,
keys: scheduledRecordingKeys,
);
}
if (!mounted) return;
setState(() => _scheduledRecordingKeys = scheduledRecordingKeys);
}
Future<void> _addScheduledRecordingKeysForServer({
required MediaServerClient client,
required String serverId,
required Set<String> keys,
}) async {
if (!client.capabilities.liveTvDvr) return;
try {
final grabs = await client.liveTv.fetchScheduledRecordings();
for (final grab in grabs) {
if (!_isActiveScheduledGrab(grab)) continue;
final program = grab.program;
if (program == null) continue;
keys.addAll(_recordingKeysForProgram(program, fallbackServerId: serverId));
}
} catch (e) {
appLogger.d('Failed to load scheduled recordings for $serverId', error: e);
}
}
bool _isActiveScheduledGrab(MediaGrabOperation grab) {
final status = grab.status?.toLowerCase();
return status == null || status.isEmpty || status == 'scheduled' || status == 'grabbing';
}
bool _isRecordingScheduled(LiveTvProgram program) {
final keys = _recordingKeysForProgram(program);
return keys.any(_scheduledRecordingKeys.contains);
}
void _handleRecordingStateChanged(LiveTvProgram program, bool isScheduled) {
final keys = _recordingKeysForProgram(program);
if (keys.isNotEmpty) {
setState(() {
final next = {..._scheduledRecordingKeys};
if (isScheduled) {
next.addAll(keys);
} else {
next.removeAll(keys);
}
_scheduledRecordingKeys = next;
});
}
unawaited(_refreshScheduledRecordingKeys());
}
Set<String> _recordingKeysForProgram(LiveTvProgram program, {String? fallbackServerId}) {
final serverId = _nonEmpty(program.serverId) ?? _nonEmpty(fallbackServerId);
if (serverId == null) return const <String>{};
final keys = <String>{};
void addMediaId(String? value) {
final normalized = _nonEmpty(value);
if (normalized != null) keys.add(_recordingKey(serverId, 'media', normalized));
}
addMediaId(program.ratingKey);
addMediaId(program.guid);
addMediaId(program.key);
final channelIdentifier = _nonEmpty(program.channelIdentifier);
final beginsAt = program.beginsAt;
if (channelIdentifier != null && beginsAt != null) {
keys.add(_recordingKey(serverId, 'slot', '$channelIdentifier|$beginsAt|${program.endsAt ?? ''}'));
}
return keys;
}
String _recordingKey(String serverId, String type, String value) => '$serverId\u0000$type\u0000$value';
String? _nonEmpty(String? value) {
final trimmed = value?.trim();
return trimmed == null || trimmed.isEmpty ? null : trimmed;
}
void _scrollToNow() {
WidgetsBinding.instance.addPostFrameCallback((_) {
final now = DateTime.now();
@@ -993,6 +1099,7 @@ class GuideTabState extends State<GuideTab> with MountedSetStateMixin {
}) {
final isCurrentlyAiring = program.isCurrentlyAiring;
final isPast = program.endsAt != null && program.endsAt! < DateTime.now().millisecondsSinceEpoch ~/ 1000;
final isRecordingScheduled = _isRecordingScheduled(program);
Color materialColor;
if (isFocused) {
@@ -1052,11 +1159,24 @@ class GuideTabState extends State<GuideTab> with MountedSetStateMixin {
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
program.grandparentTitle ?? program.title,
style: theme.textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w600, color: titleColor),
maxLines: 1,
overflow: TextOverflow.ellipsis,
Row(
children: [
if (isRecordingScheduled) ...[
_RecordingDot(color: Colors.red, tooltip: t.liveTv.recordingScheduled),
const SizedBox(width: 5),
],
Expanded(
child: Text(
program.grandparentTitle ?? program.title,
style: theme.textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w600,
color: titleColor,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
),
if (program.grandparentTitle != null)
Text(
@@ -1105,6 +1225,29 @@ class GuideTabState extends State<GuideTab> with MountedSetStateMixin {
posterUrl: posterUrl,
onTuneChannel: () => _tuneChannel(channel),
client: client,
onRecordingStateChanged: (isScheduled) => _handleRecordingStateChanged(program, isScheduled),
);
}
}
class _RecordingDot extends StatelessWidget {
final Color color;
final String tooltip;
const _RecordingDot({required this.color, required this.tooltip});
@override
Widget build(BuildContext context) {
return Tooltip(
message: tooltip,
child: Semantics(
label: tooltip,
child: Container(
width: 8,
height: 8,
decoration: BoxDecoration(color: color, shape: BoxShape.circle),
),
),
);
}
}