Skip to content

Commit 54dcb86

Browse files
committed
feat(win,android): implement timeline seek control
- Added SEEK command to the UDP protocol\n- Windows: Replaced ProgressBar with Slider, bound to SeekCommand\n- Windows: Handled SMTC seek requests to forward to Android\n- Android: Replaced LinearProgressIndicator with Slider, sending SEEK action to service\n- Android: Handled incoming SEEK requests to control the local media session
1 parent 5eebe80 commit 54dcb86

10 files changed

Lines changed: 109 additions & 20 deletions

File tree

Android/app/src/main/java/com/jayfunc/carpecast/MainActivity.kt

Lines changed: 30 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ import androidx.compose.foundation.layout.ColumnScope
3636
import androidx.compose.foundation.layout.Row
3737
import androidx.compose.foundation.layout.Spacer
3838
import androidx.compose.foundation.layout.aspectRatio
39+
import androidx.compose.foundation.layout.fillMaxHeight
3940
import androidx.compose.foundation.layout.fillMaxSize
4041
import androidx.compose.foundation.layout.fillMaxWidth
4142
import androidx.compose.foundation.layout.height
@@ -73,6 +74,7 @@ import androidx.compose.material3.OutlinedButton
7374
import androidx.compose.material3.OutlinedTextField
7475
import androidx.compose.material3.RadioButton
7576
import androidx.compose.material3.Scaffold
77+
import androidx.compose.material3.Slider
7678
import androidx.compose.material3.Surface
7779
import androidx.compose.material3.Text
7880
import androidx.compose.material3.TextButton
@@ -86,6 +88,7 @@ import androidx.compose.runtime.Composable
8688
import androidx.compose.runtime.DisposableEffect
8789
import androidx.compose.runtime.LaunchedEffect
8890
import androidx.compose.runtime.getValue
91+
import androidx.compose.runtime.mutableFloatStateOf
8992
import androidx.compose.runtime.mutableIntStateOf
9093
import androidx.compose.runtime.mutableStateOf
9194
import androidx.compose.runtime.remember
@@ -481,15 +484,31 @@ fun PlayerScreen(state: MediaState) {
481484

482485
// Progress
483486
Column(modifier = Modifier.fillMaxWidth()) {
484-
val progress = if (state.duration > 0) state.position.toFloat() / state.duration else 0f
485-
LinearProgressIndicator(
486-
progress = progress.coerceIn(0f, 1f),
487-
modifier = Modifier
488-
.fillMaxWidth()
489-
.height(6.dp)
490-
.clip(RoundedCornerShape(50)),
491-
color = MaterialTheme.colorScheme.primary,
492-
trackColor = MaterialTheme.colorScheme.primaryContainer
487+
var sliderValue by remember { mutableFloatStateOf(0f) }
488+
var isDragging by remember { mutableStateOf(false) }
489+
490+
LaunchedEffect(state.position, state.duration, isDragging) {
491+
if (!isDragging) {
492+
sliderValue = if (state.duration > 0) state.position.toFloat() / state.duration else 0f
493+
}
494+
}
495+
496+
Slider(
497+
value = sliderValue.coerceIn(0f, 1f),
498+
onValueChange = {
499+
isDragging = true
500+
sliderValue = it
501+
},
502+
onValueChangeFinished = {
503+
isDragging = false
504+
val targetPos = (sliderValue * state.duration).toLong()
505+
val intent = Intent(context, MediaSyncService::class.java).apply {
506+
action = "ACTION_SEEK"
507+
putExtra("position", targetPos)
508+
}
509+
context.startService(intent)
510+
},
511+
modifier = Modifier.fillMaxWidth()
493512
)
494513

495514
Row(
@@ -498,8 +517,9 @@ fun PlayerScreen(state: MediaState) {
498517
.padding(top = 8.dp),
499518
horizontalArrangement = Arrangement.SpaceBetween
500519
) {
520+
val currentDisplayPosition = if (isDragging) (sliderValue * state.duration).toLong() else state.position
501521
Text(
502-
formatTime(state.position),
522+
formatTime(currentDisplayPosition),
503523
style = MaterialTheme.typography.labelMedium,
504524
color = MaterialTheme.colorScheme.onSurfaceVariant
505525
)

Android/app/src/main/java/com/jayfunc/carpecast/MediaSyncService.kt

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -434,6 +434,12 @@ class MediaSyncService : NotificationListenerService() {
434434

435435
"ACTION_NEXT" -> controller?.transportControls?.skipToNext()
436436
"ACTION_PREV" -> controller?.transportControls?.skipToPrevious()
437+
"ACTION_SEEK" -> {
438+
val position = intent.getLongExtra("position", -1L)
439+
if (position != -1L) {
440+
controller?.transportControls?.seekTo(position)
441+
}
442+
}
437443
}
438444
}
439445
return START_STICKY
@@ -449,6 +455,16 @@ class MediaSyncService : NotificationListenerService() {
449455

450456
val controller = activeControllers.firstOrNull() ?: return@post
451457
val transportControls = controller.transportControls
458+
459+
if (cmd.startsWith("SEEK:")) {
460+
val posStr = cmd.removePrefix("SEEK:")
461+
val pos = posStr.toLongOrNull()
462+
if (pos != null) {
463+
transportControls.seekTo(pos)
464+
}
465+
return@post
466+
}
467+
452468
when (cmd) {
453469
"TOGGLE_PLAY" -> {
454470
if (controller.playbackState?.state == PlaybackState.STATE_PLAYING) {

Windows/Services/INetworkService.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,5 +30,6 @@ public interface INetworkService
3030
void StopListening();
3131
Task SendCommandAsync(string command);
3232
Task SendCommandToEndpointAsync(string command, System.Net.IPEndPoint endpoint);
33+
Task SendSeekAsync(long positionMs);
3334
void DisconnectLocal();
3435
}

Windows/Services/ISmtcService.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ public interface ISmtcService
88
event EventHandler PausePressed;
99
event EventHandler NextPressed;
1010
event EventHandler PreviousPressed;
11+
event EventHandler<double> SeekRequested;
1112

1213
void Initialize();
1314
void UpdateMediaState(string title, string artist, string album, bool isPlaying, double position, double duration, string albumArtBase64 = "");

Windows/Services/NetworkService.cs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -228,6 +228,11 @@ public async Task SendCommandAsync(string command)
228228
}
229229
}
230230

231+
public async Task SendSeekAsync(long positionMs)
232+
{
233+
await SendCommandAsync($"SEEK:{positionMs}");
234+
}
235+
231236
public async Task SendCommandToEndpointAsync(string command, IPEndPoint endpoint)
232237
{
233238
if (_dataClient != null)

Windows/Services/SmtcService.cs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ public class SmtcService : ISmtcService
1313
public event EventHandler? PausePressed;
1414
public event EventHandler? NextPressed;
1515
public event EventHandler? PreviousPressed;
16+
public event EventHandler<double>? SeekRequested;
1617

1718
public void Initialize()
1819
{
@@ -25,10 +26,17 @@ public void Initialize()
2526
_smtc.IsPauseEnabled = true;
2627
_smtc.IsNextEnabled = true;
2728
_smtc.IsPreviousEnabled = true;
29+
30+
_smtc.PlaybackPositionChangeRequested += Smtc_PlaybackPositionChangeRequested;
2831

2932
_smtc.ButtonPressed += Smtc_ButtonPressed;
3033
}
3134

35+
private void Smtc_PlaybackPositionChangeRequested(SystemMediaTransportControls sender, PlaybackPositionChangeRequestedEventArgs args)
36+
{
37+
SeekRequested?.Invoke(this, args.RequestedPlaybackPosition.TotalMilliseconds);
38+
}
39+
3240
private void Smtc_ButtonPressed(SystemMediaTransportControls sender, SystemMediaTransportControlsButtonPressedEventArgs args)
3341
{
3442
switch (args.Button)

Windows/ViewModels/PlayerViewModel.cs

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ public PlayerViewModel(INetworkService networkService, ISmtcService smtcService,
7070
_smtcService.PausePressed += async (s, e) => await PlayPauseCommand.ExecuteAsync(null);
7171
_smtcService.NextPressed += async (s, e) => await NextCommand.ExecuteAsync(null);
7272
_smtcService.PreviousPressed += async (s, e) => await PreviousCommand.ExecuteAsync(null);
73+
_smtcService.SeekRequested += async (s, position) => await SeekCommand.ExecuteAsync(position);
7374

7475
DevicesVM.ActiveDeviceChanged += DevicesVM_ActiveDeviceChanged;
7576

@@ -235,6 +236,21 @@ private async Task Previous()
235236
await _networkService.SendCommandAsync("PREV");
236237
}
237238

239+
[RelayCommand]
240+
private async Task Seek(double position)
241+
{
242+
long posMs = (long)position;
243+
await _networkService.SendSeekAsync(posMs);
244+
245+
// Optimistically update position
246+
_basePosition = position;
247+
_lastUpdateTime = DateTime.Now;
248+
CurrentPosition = position;
249+
FormattedPosition = FormatTime(position);
250+
Media.Position = position;
251+
_smtcService.UpdateTimeline(position, Media.Duration);
252+
}
253+
238254
private void ResetState()
239255
{
240256
Media = new MediaState();

Windows/Views/PlayerPage.xaml

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,9 @@
33
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
44
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
55
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
6+
xmlns:labs="using:CommunityToolkit.WinUI.Controls"
67
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
78
xmlns:models="using:CarpeCast.Models"
8-
xmlns:labs="using:CommunityToolkit.WinUI.Controls"
99
Background="Transparent"
1010
mc:Ignorable="d">
1111

@@ -19,16 +19,14 @@
1919
<Border>
2020
<Border.Background>
2121
<LinearGradientBrush StartPoint="0,0" EndPoint="0,1">
22-
<GradientStop Offset="0.0" Color="#FFFFFFFF" />
23-
<GradientStop Offset="0.5" Color="#FFFFFFFF" />
24-
<GradientStop Offset="1.0" Color="#00FFFFFF" />
22+
<GradientStop Offset="0.25" Color="#CCFFFFFF" />
23+
<GradientStop Offset="0.5" Color="#66FFFFFF" />
24+
<GradientStop Offset="0.75" Color="#00FFFFFF" />
2525
</LinearGradientBrush>
2626
</Border.Background>
2727
</Border>
2828
</labs:OpacityMaskView.OpacityMask>
29-
<Image
30-
Source="{x:Bind ViewModel.AlbumArtImage, Mode=OneWay}"
31-
Stretch="UniformToFill" />
29+
<Image Source="{x:Bind ViewModel.AlbumArtImage, Mode=OneWay}" Stretch="UniformToFill" />
3230
</labs:OpacityMaskView>
3331

3432
<!-- Placeholder Icon -->
@@ -98,10 +96,13 @@
9896

9997
<!-- Progress -->
10098
<StackPanel Spacing="8">
101-
<ProgressBar
99+
<Slider
100+
x:Name="ProgressSlider"
102101
HorizontalAlignment="Stretch"
102+
IsThumbToolTipEnabled="False"
103103
Maximum="{x:Bind ViewModel.CurrentDuration, Mode=OneWay}"
104-
Value="{x:Bind ViewModel.CurrentPosition, Mode=OneWay}" />
104+
Value="{x:Bind ViewModel.CurrentPosition, Mode=OneWay}"
105+
ValueChanged="ProgressSlider_ValueChanged" />
105106
<Grid>
106107
<TextBlock
107108
HorizontalAlignment="Left"

Windows/Views/PlayerPage.xaml.cs

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
using Microsoft.UI.Xaml.Controls;
1+
using Microsoft.UI.Xaml.Controls;
22
using Microsoft.Extensions.DependencyInjection;
33
using CarpeCast.ViewModels;
44

@@ -15,4 +15,15 @@ public PlayerPage()
1515

1616
ViewModel.StartNetworking();
1717
}
18+
19+
private void ProgressSlider_ValueChanged(object sender, Microsoft.UI.Xaml.Controls.Primitives.RangeBaseValueChangedEventArgs e)
20+
{
21+
if (ViewModel == null) return;
22+
23+
// Ignore programmatic updates from the timer or SeekCommand itself
24+
if (System.Math.Abs(e.NewValue - ViewModel.CurrentPosition) > 1.0)
25+
{
26+
ViewModel.SeekCommand.Execute(e.NewValue);
27+
}
28+
}
1829
}

task.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
# Task Checklist: Implement Timeline Seek Control
2+
3+
## Protocol
4+
- [ ] Add `SEEK:<position_ms>` command format to the UDP command protocol.
5+
6+
## Windows Subagent
7+
- [x] Update `PlayerViewModel.cs`: Add `SeekCommand` (ICommand) to send `SEEK:<position_ms>` via NetworkService.
8+
- [x] Update `NetworkService.cs`: Add a method to send `SEEK:<position_ms>` to the active device.
9+
- [x] Update `PlayerPage.xaml`: Replace `ProgressBar` with a `Slider` representing progress in milliseconds. Bind it to the ViewModel and use `SeekCommand`.
10+
- [x] Update `SmtcService.cs`: Enable `IsPositionEnabled` in SMTC and listen for `PositionChangeRequested` to trigger a seek.

0 commit comments

Comments
 (0)