Skip to content

fix: restore UPnP/Cast button to now playing screen#189

Open
tbrackbill wants to merge 2 commits into
dddevid:masterfrom
tbrackbill:fix/restore-cast-button
Open

fix: restore UPnP/Cast button to now playing screen#189
tbrackbill wants to merge 2 commits into
dddevid:masterfrom
tbrackbill:fix/restore-cast-button

Conversation

@tbrackbill
Copy link
Copy Markdown
Contributor

@tbrackbill tbrackbill commented May 15, 2026

Summary

  • Restores CastButton import and instantiation in now_playing_screen.dart (2 lines)
  • Re-adds lib/widgets/cast_button.dart which was removed in 0b2a0919

Background

cast_button.dart was deleted in commit 0b2a0919 with the note "CastButton class never instantiated." The class had become unreferenced because its import and call site in now_playing_screen.dart were accidentally dropped during a prior UI reorganization — not intentionally removed. This PR restores both.

The widget provides UPnP/DLNA device discovery, Chromecast connect/disconnect, and AirPlay (iOS) support, all surfaced via a single icon button in the Now Playing top-right icon row alongside lyrics, playback speed, and sleep timer.

Test plan

  • Build and confirm Cast button appears in Now Playing top-right row
  • Tap button — device picker dialog opens and scans for UPnP/DLNA devices
  • Chromecast connect/disconnect works if a Cast device is available
  • No regressions on the rest of Now Playing UI

Summary by CodeRabbit

  • New Features
    • Added Cast button to the player controls for streaming audio to external devices
    • Supports discovery and connection to Chromecast and DLNA devices
    • Includes volume controls for managing connected cast devices
    • Device picker dialog with automatic device discovery
    • AirPlay support on iOS

Review Change Stack

tbrackbill and others added 2 commits May 14, 2026 21:28
Upstream dropped the CastButton import and call site during the UI
reorganization. Re-added to the top-right icon row alongside lyrics,
speed, and sleep timer.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Extracted from now_playing_screen so it can be used as a standalone
widget. Handles platform routing: AirPlay on iOS, UPnP + Chromecast on
Android/other.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 15, 2026

📝 Walkthrough

Walkthrough

This PR adds a cast/DLNA playback control button to the now-playing screen. The new CastButton widget detects available casting services, shows an icon with appropriate state, and opens dialogs for device control or device discovery. Connected Chromecast and UPnP devices can be managed via dedicated control dialogs with volume sliders; new devices are discovered and connected through a device picker dialog.

Changes

Cast Button Feature

Layer / File(s) Summary
CastButton widget entry point
lib/widgets/cast_button.dart
CastButton reads CastService and UpnpService state to display platform-appropriate icon/color/tooltip; routes to cast control, DLNA control, or device picker dialogs on press.
Cast device control dialog
lib/widgets/cast_button.dart
_showCastControlDialog displays connected Chromecast device name, optional media image/title/artist, and volume slider with mute/low/high icon logic via Consumer<CastService>.
DLNA device control dialog
lib/widgets/cast_button.dart
_showUpnpControlDialog displays UPnP device info and three-state volume control; _infoRow renders themed icon+text rows.
Device picker discovery and dialog
lib/widgets/cast_button.dart
_showDevicePickerDialog initiates discovery; _DevicePickerDialogState polls UPnP devices every 500ms and streams Chromecast results via StreamBuilder, rendering devices with _DeviceTile grouped under _SectionHeader, and handles connect calls with snackbar feedback.
Player header integration
lib/screens/now_playing_screen.dart
Imports CastButton and inserts it into _PlayerHeader controls.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Poem

🐰 A button for casting so fine,

With dialogs that dance and align,

Chromecast and DLNA unite,

Device discovery in flight,

Music streams where screens intertwine! 🎵

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'fix: restore UPnP/Cast button to now playing screen' directly and clearly describes the main change—restoring the Cast button feature to the Now Playing screen, which is exactly what the PR accomplishes.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Tip

💬 Introducing Slack Agent: The best way for teams to turn conversations into code.

Slack Agent is built on CodeRabbit's deep understanding of your code, so your team can collaborate across the entire SDLC without losing context.

  • Generate code and open pull requests
  • Plan features and break down work
  • Investigate incidents and troubleshoot customer tickets together
  • Automate recurring tasks and respond to alerts with triggers
  • Summarize progress and report instantly

Built for teams:

  • Shared memory across your entire org—no repeating context
  • Per-thread sandboxes to safely plan and execute work
  • Governance built-in—scoped access, auditability, and budget controls

One agent for your entire SDLC. Right inside Slack.

👉 Get started


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@lib/widgets/cast_button.dart`:
- Around line 41-55: The new hardcoded user-facing strings in the CastButton
widget (tooltip assignments, unknown device labels like "device", DLNA prefix
"DLNA:", and other literals such as "Chromecast" / disconnect / playback
messages referenced around the cast-related branches) must be replaced with
localized strings from AppLocalizations; update the tooltip assignments in the
castState connecting/connected/default branches to call
AppLocalizations.of(context)! for the connecting/castDlnaBeta messages and add
localized entries for unknown-device labels and the DLNA prefix, and similarly
swap any other hardcoded literals referenced via CastService.deviceName,
UpnpService.connectedDevice?.friendlyName, and the other occurrences noted
(around the cast/disconnect/playback strings) to use AppLocalizations keys so
all user-facing text is localized.
- Around line 560-579: The dialog is using its own (soon-to-be-disposed) context
for Snackbar: after Navigator.pop(context) the async connectToDevice(d)
completes but ScaffoldMessenger.of(context) refers to the dialog context and the
snackbar never appears; change the dialog to accept and store a parent
BuildContext (e.g. parentContext) passed from the caller and, after calling
Navigator.pop(context), use await widget.castService.connectToDevice(d) as
before but call ScaffoldMessenger.of(parentContext).showSnackBar(...) (and
parentContext.mounted checks where appropriate) so the snackbar is shown on the
parent scaffold; apply the same pattern for the DLNA/UPnP tile logic that also
calls widget.castService.connectToDevice and uses ScaffoldMessenger.of(context).
- Around line 459-460: The current check only compares lengths (devices.length
!= _upnpDevices.length) so replacements with same count are ignored; change the
condition to compare contents using listEquals from flutter/foundation (e.g., if
(!listEquals(devices, _upnpDevices))) and call setState(() => _upnpDevices =
List.of(devices)); ensuring you import foundation.dart if missing and reference
the existing _upnpDevices and devices variables and the setState call in
cast_button.dart.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: c93ec406-66e0-41a2-890a-e94a92e206e2

📥 Commits

Reviewing files that changed from the base of the PR and between 3948ed9 and 9cce0e1.

📒 Files selected for processing (2)
  • lib/screens/now_playing_screen.dart
  • lib/widgets/cast_button.dart

Comment on lines +41 to +55
'Casting to ${context.read<CastService>().deviceName ?? "device"}';
} else if (castState == CastState.connecting) {
icon = Icons.cast_connected;
color = Colors.orange;
tooltip = AppLocalizations.of(context)!.connecting;
} else if (upnpConnected) {
icon = Icons.speaker_rounded;
color = Theme.of(context).colorScheme.primary;
tooltip =
'DLNA: ${context.read<UpnpService>().connectedDevice?.friendlyName ?? "device"}';
} else {
icon = Icons.cast;
color = iconColor ?? Colors.white;
tooltip = AppLocalizations.of(context)!.castDlnaBeta;
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Localize newly introduced user-facing strings.

Several new strings are hardcoded (e.g., casting/DLNA tooltips, unknown device labels, disconnect label, playback message, “Chromecast”). These should go through AppLocalizations to avoid partial-English UI.

Also applies to: 100-101, 266-267, 310-311, 364-365, 558-558

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/widgets/cast_button.dart` around lines 41 - 55, The new hardcoded
user-facing strings in the CastButton widget (tooltip assignments, unknown
device labels like "device", DLNA prefix "DLNA:", and other literals such as
"Chromecast" / disconnect / playback messages referenced around the cast-related
branches) must be replaced with localized strings from AppLocalizations; update
the tooltip assignments in the castState connecting/connected/default branches
to call AppLocalizations.of(context)! for the connecting/castDlnaBeta messages
and add localized entries for unknown-device labels and the DLNA prefix, and
similarly swap any other hardcoded literals referenced via
CastService.deviceName, UpnpService.connectedDevice?.friendlyName, and the other
occurrences noted (around the cast/disconnect/playback strings) to use
AppLocalizations keys so all user-facing text is localized.

Comment on lines +459 to +460
if (devices.length != _upnpDevices.length) {
setState(() => _upnpDevices = List.of(devices));
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

cat -n lib/widgets/cast_button.dart | sed -n '450,470p'

Repository: dddevid/Musly

Length of output: 658


🏁 Script executed:

grep -n "_upnpDevices" lib/widgets/cast_button.dart

Repository: dddevid/Musly

Length of output: 319


🏁 Script executed:

head -30 lib/widgets/cast_button.dart

Repository: dddevid/Musly

Length of output: 955


🏁 Script executed:

rg "class UpnpDevice" -A 20

Repository: dddevid/Musly

Length of output: 1206


🏁 Script executed:

rg "List<UpnpDevice>" lib/services/upnp_service.dart -A 5 -B 5

Repository: dddevid/Musly

Length of output: 1156


🏁 Script executed:

rg "Future<List<UpnpDevice>> discover" -A 50 lib/services/upnp_service.dart

Repository: dddevid/Musly

Length of output: 1634


🏁 Script executed:

rg "\.discover\(" lib/ -B 3 -A 1

Repository: dddevid/Musly

Length of output: 337


🏁 Script executed:

rg "_fetchDeviceDescription" -A 30 lib/services/upnp_service.dart | head -50

Repository: dddevid/Musly

Length of output: 1578


🌐 Web query:

flutter listEquals foundation.dart documentation

💡 Result:

In Flutter/Dart, listEquals is documented as a function in the foundation library. It compares two lists for element-by-element equality (same length, same elements in the same order) and returns true when both lists are null; false if only one is null or lengths differ.[1] Signature (from the docs): bool listEquals(List? a, List? b).[1] Behavior details from the docs: 1) Null handling: returns true if both a and b are null.[1] 2) Length/order: if both are non-null, it returns true only if they have the same length and matching members at each index in the same order.[1] 3) Nested collections: if the list elements are maps/lists/sets/other collections, their contents are not compared “deeply” unless the elements’ own == operators handle it.[1] The docs recommend DeepCollectionEquality from the collection package for deep equality.[1] 4) See also in the same doc: setEquals and mapEquals for similar equality behavior for those collection types.[1] Source: https://api.flutter.dev/flutter/foundation/listEquals.html [1].

Citations:


UPnP device refresh misses changes when device count stays the same.

When devices are replaced (e.g., 1 removed + 1 added) without changing total count, the length comparison devices.length != _upnpDevices.length skips the setState, leaving stale tiles in the picker. Since UpnpDevice has no custom equality operator, listEquals will detect the reference changes from newly created instances and properly trigger refresh.

Suggested fix
+import 'package:flutter/foundation.dart' show listEquals;
-        if (devices.length != _upnpDevices.length) {
+        if (!listEquals(devices, _upnpDevices)) {
           setState(() => _upnpDevices = List.of(devices));
         }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/widgets/cast_button.dart` around lines 459 - 460, The current check only
compares lengths (devices.length != _upnpDevices.length) so replacements with
same count are ignored; change the condition to compare contents using
listEquals from flutter/foundation (e.g., if (!listEquals(devices,
_upnpDevices))) and call setState(() => _upnpDevices = List.of(devices));
ensuring you import foundation.dart if missing and reference the existing
_upnpDevices and devices variables and the setState call in cast_button.dart.

Comment on lines +560 to +579
onTap: () async {
Navigator.pop(context);
final ok = await widget.castService.connectToDevice(d);
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
ok
? AppLocalizations.of(
context,
)!.connectedToDevice(d.friendlyName)
: AppLocalizations.of(
context,
)!.failedToConnectDevice(d.friendlyName),
),
backgroundColor: ok ? null : Colors.red,
duration: const Duration(seconds: 2),
),
);
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

fd -t f "cast_button.dart" -x cat -n {} | head -650 | tail -100

Repository: dddevid/Musly

Length of output: 4625


🏁 Script executed:

wc -l lib/widgets/cast_button.dart

Repository: dddevid/Musly

Length of output: 90


🏁 Script executed:

cat -n lib/widgets/cast_button.dart | sed -n '540,625p'

Repository: dddevid/Musly

Length of output: 4181


🏁 Script executed:

cat -n lib/widgets/cast_button.dart | sed -n '480,560p'

Repository: dddevid/Musly

Length of output: 3622


🏁 Script executed:

cat -n lib/widgets/cast_button.dart | sed -n '450,520p'

Repository: dddevid/Musly

Length of output: 2749


🏁 Script executed:

cat -n lib/widgets/cast_button.dart | sed -n '420,460p'

Repository: dddevid/Musly

Length of output: 1454


🏁 Script executed:

cat -n lib/widgets/cast_button.dart | sed -n '1,100p' | grep -n "class\|_DevicePickerDialog"

Repository: dddevid/Musly

Length of output: 110


🏁 Script executed:

cat -n lib/widgets/cast_button.dart | sed -n '380,430p'

Repository: dddevid/Musly

Length of output: 1627


🏁 Script executed:

rg "_DevicePickerDialog\(" lib/widgets/cast_button.dart -B 10 -A 2

Repository: dddevid/Musly

Length of output: 777


🏁 Script executed:

cat -n lib/widgets/cast_button.dart | grep -A 30 "class _DeviceTile"

Repository: dddevid/Musly

Length of output: 1184


🏁 Script executed:

python3 << 'EOF'
# The flow is:
# 1. showDialog(context: context, builder: (context) => _DevicePickerDialog(...))
#    - The builder receives a NEW context (dialog context)
#
# 2. In the StreamBuilder inside the dialog:
#    builder: (context, snapshot) { ... onTap: () async { Navigator.pop(context) ...
#    - This context is the dialog context (from the showDialog builder)
#
# 3. Navigator.pop(context) removes the dialog from the navigation stack
#    - The dialog widget is disposed
#    - The dialog context is no longer mounted
#
# 4. After await, context.mounted is checked
#    - For the dialog context, this will be FALSE
#    - The if block won't execute, so no snackbar shows
#
# The review comment is correct: the snackbar won't show because we're using
# the dialog context which gets disposed after Navigator.pop

# The fix is to pass the parent context (from _showDevicePickerDialog)
# to the dialog widget so it can be used for ScaffoldMessenger

print("Context flow analysis:")
print("✓ Parent context (from _showDevicePickerDialog) is available and valid")
print("✓ Dialog context (from showDialog builder) becomes invalid after Navigator.pop")
print("✓ The code uses context.mounted check but it still fails silently")
print("✓ The suggested fix (pass parent context) is correct")
EOF

Repository: dddevid/Musly

Length of output: 349


🏁 Script executed:

cat -n lib/widgets/cast_button.dart | sed -n '503,510p'

Repository: dddevid/Musly

Length of output: 449


Pass parent context to dialog for showing snackbars.

After device selection, snackbars won't display because the dialog context is disposed immediately by Navigator.pop(context) before the async operation completes. The context.mounted check prevents crashes but the snackbar is never shown since ScaffoldMessenger uses the unmounted dialog context.

Pass the parent context to the dialog widget and use it for displaying feedback after the device connection attempt. This issue affects both the Chromecast (lines 560–579) and DLNA/UPnP (lines 598–617) device tiles.

Suggested fix
 class _DevicePickerDialog extends StatefulWidget {
   final bool isDark;
+  final BuildContext hostContext;
   final GoogleCastDiscoveryManagerPlatformInterface discoveryManager;
   final CastService castService;
   final UpnpService upnpService;

   const _DevicePickerDialog({
     required this.isDark,
+    required this.hostContext,
     required this.discoveryManager,
     required this.castService,
     required this.upnpService,
   });
 }
       builder: (context) => _DevicePickerDialog(
         isDark: isDark,
+        hostContext: context,
         discoveryManager: discoveryManager,
         castService: castService,
         upnpService: upnpService,
       ),
-                        if (context.mounted) {
-                          ScaffoldMessenger.of(context).showSnackBar(
+                        if (widget.hostContext.mounted) {
+                          ScaffoldMessenger.of(widget.hostContext).showSnackBar(
                             SnackBar(
-                        if (context.mounted) {
-                          ScaffoldMessenger.of(context).showSnackBar(
+                        if (widget.hostContext.mounted) {
+                          ScaffoldMessenger.of(widget.hostContext).showSnackBar(
                             SnackBar(
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/widgets/cast_button.dart` around lines 560 - 579, The dialog is using its
own (soon-to-be-disposed) context for Snackbar: after Navigator.pop(context) the
async connectToDevice(d) completes but ScaffoldMessenger.of(context) refers to
the dialog context and the snackbar never appears; change the dialog to accept
and store a parent BuildContext (e.g. parentContext) passed from the caller and,
after calling Navigator.pop(context), use await
widget.castService.connectToDevice(d) as before but call
ScaffoldMessenger.of(parentContext).showSnackBar(...) (and parentContext.mounted
checks where appropriate) so the snackbar is shown on the parent scaffold; apply
the same pattern for the DLNA/UPnP tile logic that also calls
widget.castService.connectToDevice and uses ScaffoldMessenger.of(context).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant