Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CmdPal.Ext.Spotify.sln
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution
.github\FUNDING.yml = .github\FUNDING.yml
LICENSE = LICENSE
README.md = README.md
Fiatsoft.README.md = Fiatsoft.README.md
EndProjectSection
EndProject
Global
Expand Down
Binary file added CmdPal.Ext.Spotify/Assets/Dark/album.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added CmdPal.Ext.Spotify/Assets/Dark/device.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added CmdPal.Ext.Spotify/Assets/Dark/refresh.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added CmdPal.Ext.Spotify/Assets/Dark/speaker.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added CmdPal.Ext.Spotify/Assets/Light/album.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added CmdPal.Ext.Spotify/Assets/Light/device.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added CmdPal.Ext.Spotify/Assets/Light/refresh.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added CmdPal.Ext.Spotify/Assets/Light/speaker.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
10 changes: 5 additions & 5 deletions CmdPal.Ext.Spotify/CmdPal.Ext.Spotify.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -30,11 +30,6 @@
<Content Include="Assets\Wide310x150Logo.scale-200.png" />
</ItemGroup>

<ItemGroup>
<Folder Include="Assets\Dark\" />
<Folder Include="Assets\Light\" />
</ItemGroup>

<ItemGroup>
<Manifest Include="$(ApplicationManifest)" />
</ItemGroup>
Expand All @@ -52,6 +47,11 @@
<PackageReference Include="System.Text.Json" />
<PackageReference Include="Shmuelie.WinRTServer" />
</ItemGroup>
<ItemGroup>
<EmbeddedResource Update="Properties\Resources.resx">
<Generator></Generator>
</EmbeddedResource>
</ItemGroup>

<PropertyGroup Condition="'$(DisableHasPackageAndPublishMenuAddedByProject)'!='true' and '$(EnableMsixTooling)'=='true'">
<HasPackageAndPublishMenu>true</HasPackageAndPublishMenu>
Expand Down
88 changes: 40 additions & 48 deletions CmdPal.Ext.Spotify/Commands/AddToQueueCommand.cs
Original file line number Diff line number Diff line change
@@ -1,59 +1,51 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using CmdPal.Ext.Spotify.Helpers;
using CmdPal.Ext.Spotify.Helpers;
using CmdPal.Ext.Spotify.Properties;
using Microsoft.CommandPalette.Extensions;
using Microsoft.CommandPalette.Extensions.Toolkit;
using SpotifyAPI.Web;
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using System.Xml.Linq;

namespace CmdPal.Ext.Spotify.Commands;

internal sealed partial class AddToQueueCommand : PlayerCommand<PlayerAddToQueueRequest>
namespace CmdPal.Ext.Spotify.Commands
{
private object _item;

private AddToQueueCommand(SpotifyClient spotifyClient, object item) : base(spotifyClient, new PlayerAddToQueueRequest("spotify:track:xxxx"))
{
_item = item;
Name = Resources.ContextMenuResultAddToQueueTitle;
Icon = Icons.AddQueue;
}

public AddToQueueCommand(SpotifyClient spotifyClient, FullTrack track) : this(spotifyClient, (object)track)
internal sealed partial class AddToQueueCommand : PlayerCommand<PlayerAddToQueueRequest>
{
}

public AddToQueueCommand(SpotifyClient spotifyClient, SimpleAlbum album) : this(spotifyClient, (object)album)
{
}

public override CommandResult Invoke()
{
// each track is queued sequentially to preserve ordering
// UI would be blocked for a while if we weret to wait for all those requests to finish
Task.Run(() => EnsureActiveDeviceAsync(InvokeAsync));
return CommandResult.Hide();
}

protected override async Task InvokeAsync(IPlayerClient player, PlayerAddToQueueRequest requestParams)
{
List<string>? uris = null;
switch (_item)
public AddToQueueCommand(SpotifyClient spotifyClient, PlayerAddToQueueRequest requestParams) : base(spotifyClient, requestParams)
{
case FullTrack track:
uris = [track.Uri];
break;

case SimpleAlbum album:
var tracks = await spotifyClient.Albums.GetTracks(album.Id);
uris = (await spotifyClient.PaginateAll(tracks)).Select(track => track.Uri).ToList();
break;
Name = Resources.ContextMenuResultAddToQueueTitle;
Icon = Icons.AddQueue;
}

default: throw new NotImplementedException("Item type not implemented");
};
public AddToQueueCommand(SpotifyClient spotifyClient, string uri) : this(spotifyClient, new PlayerAddToQueueRequest(uri))
{
}

foreach (var uri in uris)
await player.AddToQueue(new PlayerAddToQueueRequest(uri));
protected override async Task InvokeAsync(IPlayerClient player, PlayerAddToQueueRequest requestParams)
{
//await player.AddToQueue(requestParams);
try
{
if (await spotifyClient.Player.AddToQueue(requestParams))
new ToastStatusMessage(new StatusMessage() {
Message = Resources.ContextMenuResultAddToQueueTitle,
State = MessageState.Success
}).Show();
else
throw new InvalidOperationException(Resources.EmptyErrorTitle);
}
catch (Exception ex)
{
new ToastStatusMessage(new StatusMessage() {
Message = Resources.ErrorAddToQueueToast,
State = MessageState.Error
}).Show();
Journal.Append($"{Resources.ResourceManager.GetString("ErrorAddToQueueToast", CultureInfo.InvariantCulture)}: {ex.Message}", label: Journal.Label.Error);
}
}
}
}
24 changes: 16 additions & 8 deletions CmdPal.Ext.Spotify/Commands/LoginCommand.cs
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
using Microsoft.CommandPalette.Extensions.Toolkit;
using SpotifyAPI.Web.Auth;
using CmdPal.Ext.Spotify.Helpers;
using CmdPal.Ext.Spotify.Properties;
using Microsoft.CommandPalette.Extensions;
using Microsoft.CommandPalette.Extensions.Toolkit;
using Newtonsoft.Json;
using SpotifyAPI.Web;
using System.Collections.Generic;
using System.Threading.Tasks;
using SpotifyAPI.Web.Auth;
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using Newtonsoft.Json;
using System.Threading.Tasks;

namespace CmdPal.Ext.Spotify.Commands;

Expand Down Expand Up @@ -63,17 +67,21 @@ private async Task InvokeAsync()
Scope = new List<string>
{
Scopes.UserReadPlaybackState,
Scopes.UserModifyPlaybackState
Scopes.UserModifyPlaybackState,
Scopes.UserReadCurrentlyPlaying,
Scopes.UserTopRead,
Scopes.UserReadEmail
}
};

try
{
BrowserUtil.Open(loginRequest.ToUri());
}
catch (Exception)
catch (Exception ex)
{
// TODO: notify user somehow?
new ToastStatusMessage(new StatusMessage() { Message = Resources.ErrorLoginToast, State = MessageState.Error }).Show();
Journal.Append($"{Resources.ResourceManager.GetString("ErrorLoginToast", CultureInfo.InvariantCulture)}: {ex.Message}", label: Journal.Label.Error);
return;
}

Expand Down
128 changes: 86 additions & 42 deletions CmdPal.Ext.Spotify/Commands/PlayerCommand.cs
Original file line number Diff line number Diff line change
@@ -1,13 +1,21 @@
using Microsoft.CommandPalette.Extensions.Toolkit;
using CmdPal.Ext.Spotify.Helpers;
using CmdPal.Ext.Spotify.Pages;
using CmdPal.Ext.Spotify.Properties;
using Microsoft.CommandPalette.Extensions;
using Microsoft.CommandPalette.Extensions.Toolkit;
using Microsoft.UI.Xaml;
using Newtonsoft.Json;
using SpotifyAPI.Web;
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using System.Threading;
using System;
using System.Net;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;

namespace CmdPal.Ext.Spotify.Commands;

Expand All @@ -29,11 +37,50 @@ T requestParams

public override CommandResult Invoke()
{
Journal.Append(JsonConvert.SerializeObject(this));
EnsureActiveDeviceAsync(InvokeAsync).GetAwaiter().GetResult();
return GetCommandResult(this);
}

private CommandResult GetCommandResult(PlayerCommand<T> playerCommand)
{
if (SpotifyCommandsProvider.SettingsManager.CommandResults.TryGetValue(playerCommand.GetType().Name, out var setting))
return GetCommandResult(setting.Value);
return CommandResult.Hide();
}

protected abstract Task InvokeAsync(IPlayerClient player,T requestParams);
private CommandResult GetCommandResult(string? value)
{
return value switch
{
"KeepOpen" => CommandResult.KeepOpen(),
"GoHome" => CommandResult.GoHome(),
"Hide" or null => CommandResult.Hide(),
_ => CommandResult.Hide()
};
}

protected abstract Task InvokeAsync(IPlayerClient player, T requestParams);

//prefer the host's app before web-player, and 'newer' web-player (with higher-index) than 'older'; ignore mobile players
private Device? SelectBestDevice(IList<Device> devices)
{
var hostname = Environment.MachineName;

var matchHostname = devices.FirstOrDefault(d =>
d.Type?.Equals("Computer", StringComparison.OrdinalIgnoreCase) == true &&
d.Name?.Contains(hostname, StringComparison.OrdinalIgnoreCase) == true
);
if (matchHostname != null) return matchHostname;

var webPlayer = devices.LastOrDefault(d =>
d.Type?.Equals("Web", StringComparison.OrdinalIgnoreCase) == true ||
d.Name?.Contains("Web Player", StringComparison.OrdinalIgnoreCase) == true
);
if (webPlayer != null) return webPlayer;

return devices.LastOrDefault();
}

protected async Task EnsureActiveDeviceAsync(Func<IPlayerClient, T, Task> callback)
{
Expand All @@ -42,55 +89,52 @@ protected async Task EnsureActiveDeviceAsync(Func<IPlayerClient, T, Task> callba
if (deviceIdProperty == null)
throw new InvalidOperationException($"Request of type {requestType.Name} does not need an active device.");

bool attemptedWithCachedDevices = false;

try
{
await callback(spotifyClient.Player, requestParams);
return;
}
catch (APIException exception)
catch (APIException ex) when (ex.Response?.StatusCode == HttpStatusCode.NotFound)
{
if (exception.Response?.StatusCode != HttpStatusCode.NotFound)
throw;

var possiblePaths = new List<string>
{
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), "Spotify", "Spotify.exe"),
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFilesX86), "Spotify", "Spotify.exe"),
};

var windowsAppsPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Microsoft", "WindowsApps");
new ToastStatusMessage(new StatusMessage() { Message = Resources.PlayerCommandSessionHealingToast, State = MessageState.Info }).Show();
Journal.Append($"{Resources.ResourceManager.GetString("PlayerCommandSessionHealingToast", CultureInfo.InvariantCulture)} due to: {ex.Message}: {JsonConvert.SerializeObject(this)}", label: Journal.Label.Information);
}

if (Directory.Exists(windowsAppsPath))
// 🧊 Attempt to load from local cache
var cachedDevices = Cache.GetDevices();
if (cachedDevices != null && cachedDevices?.Count > 0)
{
var selected = SelectBestDevice(cachedDevices);
if (selected != null)
{
var subDirectories = Directory.GetDirectories(windowsAppsPath, "SpotifyAB.SpotifyMusic_*");
foreach (string subDirectory in subDirectories)
deviceIdProperty.SetValue(requestParams, selected.Id);
attemptedWithCachedDevices = true;
try
{
var exePath = Path.Combine(subDirectory, "Spotify.exe");
if (File.Exists(exePath))
possiblePaths.Add(exePath);
await callback(spotifyClient.Player, requestParams);
return;
}
catch (KeyNotFoundException)
{
Journal.Append($"⚠ Cached deviceId no longer valid. Will re-query devices; {JsonConvert.SerializeObject(this)}");
}
}
}

foreach (var path in possiblePaths)
{
if (!File.Exists(path))
continue;

if (Process.Start(path) == null)
throw new ApplicationException($"Failed to start process {path}");

Thread.Sleep(1000 * 10); // wait for Spotify to open

var deviceResponse = await spotifyClient.Player.GetAvailableDevices();
var device = deviceResponse.Devices.FirstOrDefault(x => x.Name == Environment.MachineName);

deviceIdProperty.SetValue(requestParams, device?.Id);

await callback(spotifyClient.Player, requestParams);
return;
}

throw new ApplicationException("Could not find the Spotify executable");
if (cachedDevices == null || attemptedWithCachedDevices)
{
var freshDevices = (await spotifyClient.Player.GetAvailableDevices()).Devices;
Cache.SaveDevices(freshDevices);
var selected = SelectBestDevice(freshDevices);
if (selected == null)
throw new InvalidOperationException(Resources.PlayerCommandDeviceSelectionFailedEx);
deviceIdProperty.SetValue(requestParams, selected.Id);
await callback(spotifyClient.Player, requestParams);
return;
}

throw new InvalidOperationException(Resources.PlayerCommandDeviceRetrievalFailedEx);
}
}
39 changes: 39 additions & 0 deletions CmdPal.Ext.Spotify/Commands/TransferPlaybackCommand.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
using CmdPal.Ext.Spotify.Helpers;
using CmdPal.Ext.Spotify.Properties;
using Microsoft.CommandPalette.Extensions.Toolkit;
using SpotifyAPI.Web;
using System.Threading.Tasks;

namespace CmdPal.Ext.Spotify.Commands;

internal sealed partial class TransferPlaybackCommand : PlayerCommand<PlayerTransferPlaybackRequest>
{
public TransferPlaybackCommand(SpotifyClient spotifyClient, string deviceId, string deviceName)
: base(spotifyClient, new PlayerTransferPlaybackRequest(deviceId))
{
Name = string.Format(Resources.TransferPlaybackCommandName, deviceName);
Icon = Icons.Device;
}

protected override async Task InvokeAsync(IPlayerClient player, PlayerTransferPlaybackRequest requestParams)
{
var transferRequest = new SpotifyAPI.Web.PlayerTransferPlaybackRequest(new[] { requestParams.DeviceId })
{
Play = true //requestParams.Play
};

await player.TransferPlayback(transferRequest);
}
}

public class PlayerTransferPlaybackRequest : RequestParams
{
public string DeviceId { get; set; }
public bool Play { get; set; } = false;

public PlayerTransferPlaybackRequest(string deviceId, bool play = false)
{
DeviceId = deviceId;
Play = play;
}
}
1 change: 1 addition & 0 deletions CmdPal.Ext.Spotify/GenerateResourcesDesigner.bat
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
call "InitVsEnv.bat"
cd "%~dp0\Properties"
resgen Resources.resx CmdPal.Ext.Spotify.Properties.Resources.resources /str:CSharp,CmdPal.Ext.Spotify.Properties,Resources,Resources.Designer.cs
Loading