Compare commits

...

26 Commits

Author SHA1 Message Date
EinTim23 7d4bc6dd89 - fix lastfm scrobbling on disabled apps 2025-05-13 14:08:29 +02:00
EinTim23 fa29020cea - auto retry last fm on fail 2025-05-13 14:01:01 +02:00
EinTim23 36be257462 - fix lastfm crash if internet is not present 2025-05-13 13:41:34 +02:00
EinTim23 958def7527 updated screenshots 2025-05-13 00:57:04 +02:00
EinTim23 28a198983c experimental arm64 linux 2025-05-12 23:34:32 +02:00
EinTim23 374a95e68d new icon 2025-05-12 23:00:53 +02:00
EinTim23 49e1bb0262 try to fix arm64 github actions build 2025-05-12 22:36:28 +02:00
EinTim23 cfecd99858 set specific target for github actions 2025-05-12 21:48:44 +02:00
EinTim23 c805dfe829 bump discord-rpc for windows arm64 compatibility 2025-05-12 21:36:42 +02:00
EinTim23 c3bf12a997 - add windows arm64 build 2025-05-12 16:45:22 +02:00
EinTim23 5428f57891 - add tooltips to buttons
- fix alignment on mac os
- repositioned add application button
2025-05-11 19:09:08 +02:00
EinTim23 66a25e62f2 add proper exception handling to winrt 2025-05-10 22:13:36 +02:00
EinTim23 6807fe00fa convenience improvement: automatically add last detected app to applist when you create a new app 2025-05-10 21:58:27 +02:00
EinTim23 4e57f272a1 removed unnecessary padding from processBox 2025-05-10 21:47:32 +02:00
EinTim23 7fc1ac5d5d - fix crash if process_names are ommitted
- minor ui improvements
2025-05-10 21:43:01 +02:00
EinTim23 a6f5d3277b Ui Update done 2025-05-10 21:18:11 +02:00
EinTim23 563080643f - added svg icon handling
- began ui update to manage apps from ui
- force utf8 only mode
- new about dialog
2025-05-10 17:26:35 +02:00
EinTim23 90607d2f5d - add support for different activity types
- switch from process naming matching to app user model id matching on windows
- updated example settings file
- added exception handling to itunes api
2025-05-10 12:27:15 +02:00
EinTim23 cd5656de63 fix crashing on mac os due to invalid stderror handle 2025-05-10 12:00:09 +02:00
EinTim c53f02b35c
Revert the change 2025-05-03 23:23:04 +02:00
EinTim dbbec3b027
Improve github actions build times 2025-05-03 23:14:35 +02:00
EinTim23 bccf4c6100 add old mediaremote way of getting playback data, as osascript way only works on sonoma and newer. 2025-05-03 22:59:52 +02:00
EinTim23 349058c32d migrated our script to jxa for cleaner code and proper json serialization 2025-05-03 22:08:03 +02:00
EinTim23 9821b6a294 fix mac os 15.4 support because apple restricted the mediaremote to processes that hold internal entitlements 2025-05-03 21:07:39 +02:00
EinTim23 8fb4af3a49 get rid of unsupported runner 2025-05-03 02:16:08 +02:00
EinTim23 191fd2c375 Bump wxWidgets 2025-05-03 02:13:19 +02:00
24 changed files with 773 additions and 316 deletions

View File

@ -10,7 +10,7 @@ on:
jobs:
build-linux:
runs-on: ubuntu-20.04
runs-on: ubuntu-22.04
steps:
- name: Checkout code
@ -46,7 +46,7 @@ jobs:
run: cmake -B build -S . -DCMAKE_BUILD_TYPE=Release
- name: Build the project
run: cmake --build build --config Release
run: cmake --build build --config Release --target PlayerLink --parallel $(nproc)
- name: Download linuxdeploy
run: |
@ -66,6 +66,63 @@ jobs:
name: PlayerLink-AppImage
path: ./*.AppImage
build-linux-arm64:
runs-on: ubuntu-22.04-arm
steps:
- name: Checkout code
uses: actions/checkout@v3
with:
submodules: recursive
fetch-depth: 0
- name: Set up CMake
uses: lukka/get-cmake@latest
with:
cmakeVersion: '3.22.0'
- name: Install dependencies
run: |
sudo apt-get update
sudo apt-get install -y \
build-essential \
libssl-dev \
libx11-dev \
libxext-dev \
libxrandr-dev \
libxinerama-dev \
libxcursor-dev \
zlib1g-dev \
libglu1-mesa-dev \
libgtk-3-dev \
libwayland-dev \
fuse
sudo modprobe fuse
- name: Configure with CMake
run: cmake -B build -S . -DCMAKE_BUILD_TYPE=Release
- name: Build the project
run: cmake --build build --config Release --target PlayerLink --parallel $(nproc)
- name: Download linuxdeploy
run: |
wget "https://github.com/linuxdeploy/linuxdeploy/releases/download/continuous/linuxdeploy-aarch64.AppImage"
wget "https://raw.githubusercontent.com/linuxdeploy/linuxdeploy-plugin-gtk/master/linuxdeploy-plugin-gtk.sh"
chmod +x linuxdeploy-aarch64.AppImage linuxdeploy-plugin-gtk.sh
sudo mv linuxdeploy-aarch64.AppImage /usr/local/bin/linuxdeploy
- name: Create AppImage
run: |
cp -r build/PlayerLink linux/AppDir/usr/bin/
linuxdeploy --appdir=linux/AppDir --plugin gtk --output appimage
- name: Upload AppImage artifact
uses: actions/upload-artifact@v4
with:
name: PlayerLink-AppImage-ARM64
path: ./*.AppImage
build-windows:
runs-on: windows-latest
@ -85,12 +142,47 @@ jobs:
run: cmake -B build -S . -DCMAKE_BUILD_TYPE=Release
- name: Build the project
run: cmake --build build --config Release
run: cmake --build build --config Release --target PlayerLink
- name: Upload Windows artifact
uses: actions/upload-artifact@v4
with:
name: PlayerLink-Windows-Executable
name: PlayerLink-Windows-AMD64
path: build/Release/*
build-windows-arm64:
runs-on: windows-latest
steps:
- name: Checkout code
uses: actions/checkout@v3
with:
submodules: recursive
fetch-depth: 0
- name: Setup right windows sdk
uses: GuillaumeFalourd/setup-windows10-sdk-action@v2.4
with:
sdk-version: 26100
- name: Set up CMake
uses: lukka/get-cmake@latest
with:
cmakeVersion: '3.22.0'
- name: Configure with CMake for ARM64
run: cmake -B build -S . -DCMAKE_SYSTEM_VERSION="10.0.26100.0" -DCMAKE_BUILD_TYPE=Release -A ARM64
- name: Build the project for ARM64
run: cmake --build build --config Release --target PlayerLink
- name: Rename ARM64 executable
run: Rename-Item build\Release\PlayerLink.exe PlayerLink-arm64.exe
- name: Upload Windows ARM64 artifact
uses: actions/upload-artifact@v4
with:
name: PlayerLink-Windows-ARM64
path: build/Release/*
build-macos:
@ -112,7 +204,7 @@ jobs:
run: cmake -B build -S . -DCMAKE_BUILD_TYPE=Release
- name: Build the project
run: cmake --build build --config Release
run: cmake --build build --config Release --target PlayerLink --parallel $(sysctl -n hw.physicalcpu)
- name: Create DMG package
run: |
@ -128,7 +220,7 @@ jobs:
create-release:
if: startsWith(github.ref, 'refs/tags/')
needs: [build-linux, build-windows, build-macos]
needs: [build-linux, build-linux-arm64, build-windows, build-windows-arm64, build-macos]
runs-on: ubuntu-latest
steps:
- name: Download artifacts
@ -148,6 +240,7 @@ jobs:
release_name: Release ${{ github.ref_name }}
draft: false
prerelease: false
- name: Upload AppImage
uses: actions/upload-release-asset@v1
env:
@ -158,6 +251,16 @@ jobs:
asset_name: PlayerLink-x86_64.AppImage
asset_content_type: application/octet-stream
- name: Upload ARM64 AppImage
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ steps.create_release.outputs.upload_url }}
asset_path: ./release-assets/PlayerLink-aarch64.AppImage
asset_name: PlayerLink-aarch64.AppImage
asset_content_type: application/octet-stream
- name: Upload Windows Executable
uses: actions/upload-release-asset@v1
env:
@ -168,6 +271,16 @@ jobs:
asset_name: PlayerLink.exe
asset_content_type: application/octet-stream
- name: Upload Windows ARM64 Executable
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ steps.create_release.outputs.upload_url }}
asset_path: ./release-assets/PlayerLink-arm64.exe
asset_name: PlayerLink-arm64.exe
asset_content_type: application/octet-stream
- name: Upload macOS DMG
uses: actions/upload-release-asset@v1
env:

View File

@ -1,11 +1,11 @@
cmake_minimum_required (VERSION 3.8)
cmake_minimum_required (VERSION 3.12)
include("cmake/create_resources.cmake")
file(GLOB_RECURSE SOURCES "src/*.cpp")
#enable objective c support on mac os, needed for wxwidgets and compile for both intel macs and apple sillicon macs
if(APPLE)
list(APPEND SOURCES "src/backends/darwin.mm" ${CMAKE_SOURCE_DIR}/osx/icon.icns)
list(APPEND SOURCES "src/backends/darwin.mm" ${CMAKE_SOURCE_DIR}/osx/icon.icns ${CMAKE_SOURCE_DIR}/osx/MediaRemote.js)
set(CMAKE_OSX_ARCHITECTURES "arm64;x86_64" CACHE STRING "" FORCE)
set(CMAKE_OSX_DEPLOYMENT_TARGET "10.15" CACHE STRING "" FORCE)
project ("PlayerLink" LANGUAGES C CXX OBJCXX)
@ -40,6 +40,7 @@ elseif(APPLE)
endif()
set_target_properties(PlayerLink PROPERTIES MACOSX_BUNDLE TRUE)
set_source_files_properties(${CMAKE_SOURCE_DIR}/osx/icon.icns PROPERTIES MACOSX_PACKAGE_LOCATION "Resources")
set_source_files_properties(${CMAKE_SOURCE_DIR}/osx/MediaRemote.js PROPERTIES MACOSX_PACKAGE_LOCATION "Resources")
set_target_properties(PlayerLink PROPERTIES MACOSX_BUNDLE_INFO_PLIST ${CMAKE_SOURCE_DIR}/osx/Info.plist)
elseif(UNIX AND NOT APPLE)
list(APPEND LIBRARIES dbus)

View File

@ -80,13 +80,13 @@ An example on how to add custom apps to the config can be found [here](./setting
4. Build the project :)
```bash
# for a release build
cmake --build build --config Release
cmake --build build --config Release --target PlayerLink
# for a debug build
cmake --build build
cmake --build build --target PlayerLink
```
## Contributing
This repository is open for contributions. You can view the current roadmap [here](https://github.com/EinTim23/PlayerLink/projects) or implement your own features and then open a pull request. Please keep your code as consistent and clean as possible.
## Credits
This project was heavily inspired by [Alexandra Aurora's MusicRPC](https://github.com/AlexandraAurora/MusicRPC) and her project may provide a better experience when being on Mac OS only.
This project was heavily inspired by [Alexandra Göttlicher's MusicRPC](https://github.com/kaethchen/MusicRPC) and her project may provide a better experience when being on Mac OS only.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

After

Width:  |  Height:  |  Size: 263 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 135 KiB

After

Width:  |  Height:  |  Size: 692 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 53 KiB

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 285 KiB

After

Width:  |  Height:  |  Size: 41 KiB

33
osx/MediaRemote.js Normal file
View File

@ -0,0 +1,33 @@
ObjC.import('Foundation');
try {
const frameworkPath = '/System/Library/PrivateFrameworks/MediaRemote.framework';
const framework = $.NSBundle.bundleWithPath($(frameworkPath));
framework.load
const MRNowPlayingRequest = $.NSClassFromString('MRNowPlayingRequest');
const playerPath = MRNowPlayingRequest.localNowPlayingPlayerPath;
const bundleID = ObjC.unwrap(playerPath.client.bundleIdentifier);
const nowPlayingItem = MRNowPlayingRequest.localNowPlayingItem;
const info = nowPlayingItem.nowPlayingInfo;
const title = info.valueForKey('kMRMediaRemoteNowPlayingInfoTitle');
const album = info.valueForKey('kMRMediaRemoteNowPlayingInfoAlbum');
const artist = info.valueForKey('kMRMediaRemoteNowPlayingInfoArtist');
const duration = info.valueForKey('kMRMediaRemoteNowPlayingInfoDuration');
const playbackStatus = info.valueForKey('kMRMediaRemoteNowPlayingInfoPlaybackRate');
const elapsed = info.valueForKey('kMRMediaRemoteNowPlayingInfoElapsedTime');
JSON.stringify({
title: ObjC.unwrap(title),
album: ObjC.unwrap(album),
artist: ObjC.unwrap(artist),
duration: ObjC.unwrap(duration),
playbackStatus: ObjC.unwrap(playbackStatus),
elapsed: ObjC.unwrap(elapsed),
player: ObjC.unwrap(bundleID)
});
} catch (error) {
JSON.stringify({ player: 'none', error: error.toString() });
}

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 285 KiB

After

Width:  |  Height:  |  Size: 817 KiB

BIN
rsrc/menubar_icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

3
rsrc/pencil.svg Normal file
View File

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="size-6">
<path d="M21.731 2.269a2.625 2.625 0 0 0-3.712 0l-1.157 1.157 3.712 3.712 1.157-1.157a2.625 2.625 0 0 0 0-3.712ZM19.513 8.199l-3.712-3.712-12.15 12.15a5.25 5.25 0 0 0-1.32 2.214l-.8 2.685a.75.75 0 0 0 .933.933l2.685-.8a5.25 5.25 0 0 0 2.214-1.32L19.513 8.2Z" />
</svg>

After

Width:  |  Height:  |  Size: 367 B

3
rsrc/plus.svg Normal file
View File

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="size-6">
<path fill-rule="evenodd" d="M14 3a2 2 0 1 0-4 0v7H3a2 2 0 1 0 0 4h7v7a2 2 0 1 0 4 0v-7h7a2 2 0 1 0 0-4h-7V3z" clip-rule="evenodd" />
</svg>

After

Width:  |  Height:  |  Size: 239 B

3
rsrc/trash.svg Normal file
View File

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="size-6">
<path fill-rule="evenodd" d="M16.5 4.478v.227a48.816 48.816 0 0 1 3.878.512.75.75 0 1 1-.256 1.478l-.209-.035-1.005 13.07a3 3 0 0 1-2.991 2.77H8.084a3 3 0 0 1-2.991-2.77L4.087 6.66l-.209.035a.75.75 0 0 1-.256-1.478A48.567 48.567 0 0 1 7.5 4.705v-.227c0-1.564 1.213-2.9 2.816-2.951a52.662 52.662 0 0 1 3.369 0c1.603.051 2.815 1.387 2.815 2.951Zm-6.136-1.452a51.196 51.196 0 0 1 3.273 0C14.39 3.05 15 3.684 15 4.478v.113a49.488 49.488 0 0 0-6 0v-.113c0-.794.609-1.428 1.364-1.452Zm-.355 5.945a.75.75 0 1 0-1.5.058l.347 9a.75.75 0 1 0 1.499-.058l-.346-9Zm5.48.058a.75.75 0 1 0-1.498-.058l-.347 9a.75.75 0 0 0 1.5.058l.345-9Z" clip-rule="evenodd" />
</svg>

After

Width:  |  Height:  |  Size: 751 B

View File

@ -5,13 +5,34 @@
"client_id": "1245257414715113573",
"enabled": true,
"name": "Spotify",
"type": 2,
"process_names": [
"org.mpris.MediaPlayer2.spotify",
"com.spotify.client",
"Spotify.exe"
],
"search_endpoint": "https://open.spotify.com/search/"
},
{
"client_id": "1337188104829665340",
"enabled": true,
"name": "Apple Music",
"type": 2,
"process_names": [
"com.apple.Music",
"AppleInc.AppleMusicWin_nzyj5cx40ttqa!APP",
"AppleMusic.exe"
],
"search_endpoint": "https://music.apple.com/search?term="
}
],
"lastfm": {
"api_key": "",
"api_secret": "",
"enabled": false,
"password": "",
"username": ""
},
"odesli": true,
"autostart": false
}

View File

@ -1,9 +1,11 @@
#include <Foundation/NSObjCRuntime.h>
#ifdef __APPLE__
#include <AppKit/AppKit.h>
#include <Cocoa/Cocoa.h>
#include <Foundation/Foundation.h>
#include <dispatch/dispatch.h>
#include <filesystem>
#include <nlohmann-json/single_include/nlohmann/json.hpp>
#include <fstream>
#include "../MediaRemote.hpp"
@ -16,65 +18,120 @@ void hideDockIcon(bool shouldHide) {
[NSApp setActivationPolicy:NSApplicationActivationPolicyRegular];
}
NSString *getFilePathFromBundle(NSString *fileName, NSString *fileType) {
NSString *filePath = [[NSBundle mainBundle] pathForResource:fileName ofType:fileType];
return filePath;
}
NSString *executeCommand(NSString *command, NSArray *arguments) {
NSTask *task = [[NSTask alloc] init];
task.launchPath = command;
task.arguments = arguments;
NSPipe *pipe = [NSPipe pipe];
task.standardOutput = pipe;
task.standardError = [NSPipe pipe];
[task launch];
NSData *data = [[pipe fileHandleForReading] readDataToEndOfFile];
NSString *output = [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding];
[task waitUntilExit];
return output;
}
std::shared_ptr<MediaInfo> backend::getMediaInformation() {
__block NSString *appName = nil;
__block NSDictionary *playingInfo = nil;
// apple decided to prevent apps not signed by them to use media remote, so we use an apple script instead. But that script only works on Sonoma or newer and the other one is arguably better, so keep the old method as well
if (@available(macOS 15.0, *)) {
static NSString *script = getFilePathFromBundle(@"MediaRemote", @"js");
NSString *output = executeCommand(@"/usr/bin/osascript", @[ @"-l", @"JavaScript", script ]);
nlohmann::json j = nlohmann::json::parse([output UTF8String]);
dispatch_group_t group = dispatch_group_create();
std::string appName = j["player"].get<std::string>();
if (appName == "none")
return nullptr;
dispatch_group_enter(group);
MRMediaRemoteGetNowPlayingApplicationPID(dispatch_get_main_queue(), ^(pid_t pid) {
if (pid > 0) {
NSRunningApplication *app = [NSRunningApplication runningApplicationWithProcessIdentifier:pid];
if (app)
appName = [[app.bundleIdentifier copy] retain];
}
dispatch_group_leave(group);
});
bool paused = j["playbackStatus"].get<int>() == 0;
dispatch_group_enter(group);
MRMediaRemoteGetNowPlayingInfo(dispatch_get_main_queue(), ^(CFDictionaryRef result) {
if (result)
playingInfo = [[(__bridge NSDictionary *)result copy] retain];
dispatch_group_leave(group);
});
std::string songTitle = j["title"].get<std::string>();
dispatch_group_wait(group, DISPATCH_TIME_FOREVER);
dispatch_release(group);
if (appName == nil || playingInfo == nil)
return nullptr;
std::string songAlbum = j["album"].get<std::string>();
bool paused = [playingInfo[(__bridge NSString *)kMRMediaRemoteNowPlayingInfoPlaybackRate] intValue] == 0;
std::string songArtist = j["artist"].get<std::string>();
NSString *title = playingInfo[(__bridge NSString *)kMRMediaRemoteNowPlayingInfoTitle];
std::string songTitle = title ? [title UTF8String] : "";
int64_t elapsedTimeMs = 0;
int64_t durationMs = 0;
try {
double durationNumber = j["duration"].get<double>();
durationMs = static_cast<int64_t>(durationNumber * 1000);
NSString *album = playingInfo[(__bridge NSString *)kMRMediaRemoteNowPlayingInfoAlbum];
std::string songAlbum = album ? [album UTF8String] : "";
double elapsedTimeNumber = j["elapsed"].get<double>();
elapsedTimeMs = static_cast<int64_t>(elapsedTimeNumber * 1000);
} catch (...) {
}
NSString *artist = playingInfo[(__bridge NSString *)kMRMediaRemoteNowPlayingInfoArtist];
std::string songArtist = artist ? [artist UTF8String] : "";
return std::make_shared<MediaInfo>(paused, songTitle, songArtist, songAlbum, appName, "", durationMs,
elapsedTimeMs);
} else {
__block NSString *appName = nil;
__block NSDictionary *playingInfo = nil;
NSData *artworkData = playingInfo[(__bridge NSString *)kMRMediaRemoteNowPlayingInfoArtworkData];
dispatch_group_t group = dispatch_group_create();
std::string thumbnailData;
if (artworkData)
thumbnailData = std::string((const char *)[artworkData bytes], [artworkData length]);
dispatch_group_enter(group);
MRMediaRemoteGetNowPlayingApplicationPID(dispatch_get_main_queue(), ^(pid_t pid) {
if (pid > 0) {
NSRunningApplication *app = [NSRunningApplication runningApplicationWithProcessIdentifier:pid];
if (app)
appName = [[app.bundleIdentifier copy] retain];
}
dispatch_group_leave(group);
});
NSNumber *elapsedTimeNumber = playingInfo[(__bridge NSString *)kMRMediaRemoteNowPlayingInfoElapsedTime];
dispatch_group_enter(group);
MRMediaRemoteGetNowPlayingInfo(dispatch_get_main_queue(), ^(CFDictionaryRef result) {
if (result)
playingInfo = [[(__bridge NSDictionary *)result copy] retain];
dispatch_group_leave(group);
});
NSNumber *durationNumber = playingInfo[(__bridge NSString *)kMRMediaRemoteNowPlayingInfoDuration];
dispatch_group_wait(group, DISPATCH_TIME_FOREVER);
dispatch_release(group);
if (appName == nil || playingInfo == nil)
return nullptr;
int64_t elapsedTimeMs = elapsedTimeNumber ? static_cast<int64_t>([elapsedTimeNumber doubleValue] * 1000) : 0;
bool paused = [playingInfo[(__bridge NSString *)kMRMediaRemoteNowPlayingInfoPlaybackRate] intValue] == 0;
int64_t durationMs = durationNumber ? static_cast<int64_t>([durationNumber doubleValue] * 1000) : 0;
NSString *title = playingInfo[(__bridge NSString *)kMRMediaRemoteNowPlayingInfoTitle];
std::string songTitle = title ? [title UTF8String] : "";
std::string appNameString = appName.UTF8String;
NSString *album = playingInfo[(__bridge NSString *)kMRMediaRemoteNowPlayingInfoAlbum];
std::string songAlbum = album ? [album UTF8String] : "";
[appName release];
[playingInfo release];
return std::make_shared<MediaInfo>(paused, songTitle, songArtist, songAlbum, appNameString, thumbnailData,
durationMs, elapsedTimeMs);
NSString *artist = playingInfo[(__bridge NSString *)kMRMediaRemoteNowPlayingInfoArtist];
std::string songArtist = artist ? [artist UTF8String] : "";
NSData *artworkData = playingInfo[(__bridge NSString *)kMRMediaRemoteNowPlayingInfoArtworkData];
std::string thumbnailData;
if (artworkData)
thumbnailData = std::string((const char *)[artworkData bytes], [artworkData length]);
NSNumber *elapsedTimeNumber = playingInfo[(__bridge NSString *)kMRMediaRemoteNowPlayingInfoElapsedTime];
NSNumber *durationNumber = playingInfo[(__bridge NSString *)kMRMediaRemoteNowPlayingInfoDuration];
int64_t elapsedTimeMs = elapsedTimeNumber ? static_cast<int64_t>([elapsedTimeNumber doubleValue] * 1000) : 0;
int64_t durationMs = durationNumber ? static_cast<int64_t>([durationNumber doubleValue] * 1000) : 0;
std::string appNameString = appName.UTF8String;
[appName release];
[playingInfo release];
return std::make_shared<MediaInfo>(paused, songTitle, songArtist, songAlbum, appNameString, thumbnailData,
durationMs, elapsedTimeMs);
}
}
std::filesystem::path backend::getConfigDirectory() {
@ -88,14 +145,14 @@ bool backend::toggleAutostart(bool enabled) {
launchAgentPath = launchAgentPath / "Library" / "LaunchAgents";
std::filesystem::create_directories(launchAgentPath);
launchAgentPath = launchAgentPath / "PlayerLink.plist";
if (!enabled && std::filesystem::exists(launchAgentPath)) {
std::filesystem::remove(launchAgentPath);
return true;
}
NSString *binaryPath = [[[NSProcessInfo processInfo] arguments][0] stringByStandardizingPath];
//I would also like to use std::format here, but well I also want to support older mac os versions.
// I would also like to use std::format here, but well I also want to support older mac os versions.
std::string formattedPlist =
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" "
"\"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n<plist version=\"1.0\">\n <dict>\n\n "

View File

@ -35,59 +35,6 @@ std::string toStdString(winrt::hstring& in) {
return result;
}
std::string getAppModelIdOfProcess(HANDLE hProc) {
UINT32 length = 0;
LONG rc = GetApplicationUserModelId(hProc, &length, NULL);
if (rc != ERROR_INSUFFICIENT_BUFFER)
return "";
PWSTR fullName = (PWSTR)malloc(length * sizeof(*fullName));
if (!fullName)
return "";
rc = GetApplicationUserModelId(hProc, &length, fullName);
if (rc != ERROR_SUCCESS) {
free(fullName);
return "";
}
winrt::hstring wideName = fullName;
std::string name = toStdString(wideName);
free(fullName);
return name;
}
std::string getProcessNameFromAppModelId(std::string appModelId) {
DWORD processes[1024];
DWORD cbNeeded;
if (!EnumProcesses(processes, sizeof(processes), &cbNeeded))
return "";
unsigned int processCount = cbNeeded / sizeof(DWORD);
for (DWORD i = 0; i < processCount; i++) {
DWORD processID = processes[i];
HANDLE hProcess = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, FALSE, processID);
if (hProcess) {
std::string modelid = getAppModelIdOfProcess(hProcess);
if (modelid != appModelId) {
CloseHandle(hProcess);
continue;
}
char exeName[MAX_PATH]{};
DWORD size = MAX_PATH;
QueryFullProcessImageNameA(hProcess, 0, exeName, &size);
std::filesystem::path exePath = exeName;
CloseHandle(hProcess);
return exePath.filename().string();
}
}
return "";
}
bool CreateShortcut(std::string source, std::string target) {
CoInitialize(nullptr);
WCHAR src[MAX_PATH];
@ -145,57 +92,55 @@ std::shared_ptr<MediaInfo> backend::getMediaInformation() {
return nullptr;
auto playbackInfo = currentSession.GetPlaybackInfo();
auto mediaProperties = currentSession.TryGetMediaPropertiesAsync().get();
auto timelineInformation = currentSession.GetTimelineProperties();
if (!mediaProperties)
try {
auto mediaProperties = currentSession.TryGetMediaPropertiesAsync().get();
auto timelineInformation = currentSession.GetTimelineProperties();
if (!mediaProperties)
return nullptr;
auto endTime = std::chrono::duration_cast<std::chrono::milliseconds>(timelineInformation.EndTime()).count();
auto elapsedTime =
std::chrono::duration_cast<std::chrono::milliseconds>(timelineInformation.Position()).count();
auto thumbnail = mediaProperties.Thumbnail();
std::string thumbnailData = "";
if (thumbnail) {
auto stream = thumbnail.OpenReadAsync().get();
size_t size = static_cast<size_t>(stream.Size());
DataReader reader(stream);
reader.LoadAsync(static_cast<uint32_t>(size)).get();
std::vector<uint8_t> buffer(size);
reader.ReadBytes(buffer);
reader.Close();
thumbnailData = std::string(buffer.begin(), buffer.end());
stream.Close();
}
std::string artist = toStdString(mediaProperties.Artist());
std::string albumName = toStdString(mediaProperties.AlbumTitle());
if (artist == "")
artist = toStdString(mediaProperties.AlbumArtist()); // Needed for some apps
if (artist.find(EM_DASH) != std::string::npos) {
albumName = artist.substr(artist.find(EM_DASH) + 3);
artist = artist.substr(0, artist.find(EM_DASH));
utils::trim(artist);
utils::trim(albumName);
}
std::string modelId = toStdString(currentSession.SourceAppUserModelId());
return std::make_shared<MediaInfo>(
playbackInfo.PlaybackStatus() == GlobalSystemMediaTransportControlsSessionPlaybackStatus::Paused,
toStdString(mediaProperties.Title()), std::move(artist), std::move(albumName), std::move(modelId),
std::move(thumbnailData), endTime, elapsedTime);
} catch (...) {
return nullptr;
auto endTime = std::chrono::duration_cast<std::chrono::milliseconds>(timelineInformation.EndTime()).count();
auto elapsedTime = std::chrono::duration_cast<std::chrono::milliseconds>(timelineInformation.Position()).count();
auto thumbnail = mediaProperties.Thumbnail();
std::string thumbnailData = "";
if (thumbnail) {
auto stream = thumbnail.OpenReadAsync().get();
size_t size = static_cast<size_t>(stream.Size());
DataReader reader(stream);
reader.LoadAsync(static_cast<uint32_t>(size)).get();
std::vector<uint8_t> buffer(size);
reader.ReadBytes(buffer);
reader.Close();
thumbnailData = std::string(buffer.begin(), buffer.end());
stream.Close();
}
std::string artist = toStdString(mediaProperties.Artist());
std::string albumName = toStdString(mediaProperties.AlbumTitle());
if (artist == "")
artist = toStdString(mediaProperties.AlbumArtist()); // Needed for some apps
if (artist.find(EM_DASH) != std::string::npos) {
albumName = artist.substr(artist.find(EM_DASH) + 3);
artist = artist.substr(0, artist.find(EM_DASH));
utils::trim(artist);
utils::trim(albumName);
}
std::string modelId = toStdString(currentSession.SourceAppUserModelId());
// I do know that this is disgusting, but for some reason microsoft decided to switch out the exe name with the
// ApplicationUserModelId in some version of windows 11. So we check if it's an exe name, if not we are on some
// newer windows version and need to get the exe name from the model id. We cannot directly work with the model id
// because it's unique per machine and therefore would mess up configs with preconfigured apps.
if (modelId.find(".exe") == std::string::npos)
modelId = getProcessNameFromAppModelId(modelId);
return std::make_shared<MediaInfo>(
playbackInfo.PlaybackStatus() == GlobalSystemMediaTransportControlsSessionPlaybackStatus::Paused,
toStdString(mediaProperties.Title()), std::move(artist), std::move(albumName), std::move(modelId),
std::move(thumbnailData), endTime, elapsedTime);
}
bool backend::init() {

View File

@ -44,13 +44,17 @@ public:
parameters["api_sig"] = getApiSignature(parameters);
std::string postBody = utils::getURLEncodedPostBody(parameters);
std::string response = utils::httpRequest(api_base, "POST", postBody);
auto j = nlohmann::json::parse(response);
if (j.contains("error"))
return j["error"].get<LASTFM_STATUS>();
try {
auto j = nlohmann::json::parse(response);
if (j.contains("error"))
return j["error"].get<LASTFM_STATUS>();
session_token = j["session"]["key"].get<std::string>();
authenticated = true;
return LASTFM_STATUS::SUCCESS;
session_token = j["session"]["key"].get<std::string>();
authenticated = true;
return LASTFM_STATUS::SUCCESS;
} catch (...) {
return LASTFM_STATUS::UNKNOWN_ERROR;
}
}
LASTFM_STATUS scrobble(std::string artist, std::string track) {

View File

@ -3,15 +3,18 @@
#include <wx/mstream.h>
#include <wx/statline.h>
#include <wx/taskbar.h>
#include <wx/hyperlink.h>
#include <wx/wx.h>
#include <chrono>
#include <cstddef>
#include <thread>
#include "backend.hpp"
#include "lastfm.hpp"
#include "rsrc.hpp"
#include "utils.hpp"
#include "wx/sizer.h"
std::string lastPlayingSong = "";
std::string lastMediaSource = "";
@ -48,17 +51,22 @@ void initLastFM(bool checkMode = false) {
lastfm = new LastFM(settings.lastfm.username, settings.lastfm.password, settings.lastfm.api_key,
settings.lastfm.api_secret);
LastFM::LASTFM_STATUS status = lastfm->authenticate();
if (status)
wxMessageBox(_("Error authenticating at LastFM!"), _("PlayerLink"), wxOK | wxICON_ERROR);
else if (checkMode)
if (status) {
delete lastfm;
lastfm = nullptr;
if (checkMode)
wxMessageBox(_("Error authenticating at LastFM!"), _("PlayerLink"), wxOK | wxICON_ERROR);
} else if (checkMode)
wxMessageBox(_("The LastFM authentication was successful."), _("PlayerLink"), wxOK | wxICON_INFORMATION);
}
void handleMediaTasks() {
initLastFM();
int64_t lastMs = 0;
while (true) {
std::this_thread::sleep_for(std::chrono::seconds(1));
if (!lastfm)
initLastFM();
auto mediaInformation = backend::getMediaInformation();
auto settings = utils::getSettings();
if (!mediaInformation) {
@ -85,14 +93,6 @@ void handleMediaTasks() {
if (shouldContinue)
continue;
if (lastPlayingSong.find(mediaInformation->songTitle + mediaInformation->songArtist +
mediaInformation->songAlbum) == std::string::npos &&
lastfm)
lastfm->scrobble(mediaInformation->songArtist, mediaInformation->songTitle);
lastPlayingSong = currentlyPlayingSong;
currentSongTitle = mediaInformation->songArtist + " - " + mediaInformation->songTitle;
std::string currentMediaSource = mediaInformation->playbackSource;
if (currentMediaSource != lastMediaSource) {
@ -102,6 +102,14 @@ void handleMediaTasks() {
auto app = utils::getApp(lastMediaSource);
if (lastPlayingSong.find(mediaInformation->songTitle + mediaInformation->songArtist +
mediaInformation->songAlbum) == std::string::npos &&
lastfm && app.enabled)
lastfm->scrobble(mediaInformation->songArtist, mediaInformation->songTitle);
lastPlayingSong = currentlyPlayingSong;
currentSongTitle = mediaInformation->songArtist + " - " + mediaInformation->songTitle;
if (!app.enabled) {
Discord_ClearPresence();
continue;
@ -110,7 +118,7 @@ void handleMediaTasks() {
std::string activityState = "by " + mediaInformation->songArtist;
DiscordRichPresence activity{};
activity.type = ActivityType::LISTENING;
activity.type = app.type;
activity.details = mediaInformation->songTitle.c_str();
activity.state = activityState.c_str();
activity.smallImageText = serviceName.c_str();
@ -143,7 +151,7 @@ void handleMediaTasks() {
}
std::string odesliUrl = utils::getOdesliURL(songInfo);
if(settings.odesli && songInfo.artworkURL != "") {
if (settings.odesli && songInfo.artworkURL != "") {
activity.button2name = "Show on Song.link";
activity.button2link = odesliUrl.c_str();
}
@ -151,27 +159,202 @@ void handleMediaTasks() {
Discord_UpdatePresence(&activity);
}
}
void SetWindowIcon(wxTopLevelWindow* win) {
const wxIcon icon = utils::loadIconFromMemory(icon_png, icon_png_size);
win->SetIcon(icon);
}
class AboutDialog : public wxDialog {
public:
AboutDialog(wxWindow* parent)
: wxDialog(parent, wxID_ANY, _("About PlayerLink"), wxDefaultPosition, wxDefaultSize,
wxDEFAULT_DIALOG_STYLE & ~wxRESIZE_BORDER) {
SetWindowIcon(this);
wxBoxSizer* mainSizer = new wxBoxSizer(wxVERTICAL);
wxStaticText* label = new wxStaticText(this, wxID_ANY, _("Made with <3 by EinTim"));
label->Wrap(300);
mainSizer->Add(label, 0, wxALL | wxALIGN_CENTER, 10);
wxStaticText* copyrightText = new wxStaticText(this, wxID_ANY, "© 2024-2025 EinTim. All rights reserved.");
copyrightText->Wrap(300);
mainSizer->Add(copyrightText, 0, wxALL | wxALIGN_CENTER, 10);
wxStaticText* creditsText =
new wxStaticText(this, wxID_ANY,
_("Credits:\n- Developer: EinTim\n- Inspiration: Alexandra Göttlicher\n- Icons from: "
"heroicons.com\n- App Icon from: localcc\n- Open source "
"projects used in this:\n wxWidgets, libcurl, libdbus, mbedtls, nlohmann-json."));
creditsText->Wrap(300);
mainSizer->Add(creditsText, 0, wxALL | wxALIGN_CENTER, 10);
wxHyperlinkCtrl* link = new wxHyperlinkCtrl(this, wxID_ANY, _("Visit my website"), _("https://eintim.dev"));
mainSizer->Add(link, 0, wxALL | wxALIGN_CENTER, 10);
wxButton* okButton = new wxButton(this, wxID_OK, _("OK"));
mainSizer->Add(okButton, 0, wxALL | wxALIGN_CENTER, 10);
this->SetSizerAndFit(mainSizer);
this->CenterOnScreen();
}
};
class EditAppDialog : public wxDialog {
public:
EditAppDialog(wxWindow* parent, const wxString title, utils::App* app)
: wxDialog(parent, wxID_ANY, title, wxDefaultPosition, wxDefaultSize,
wxDEFAULT_DIALOG_STYLE & ~wxRESIZE_BORDER) {
SetWindowIcon(this);
Bind(wxEVT_CLOSE_WINDOW, &EditAppDialog::OnClose, this);
wxBoxSizer* mainSizer = new wxBoxSizer(wxVERTICAL);
wxFlexGridSizer* formSizer = new wxFlexGridSizer(2, 0, 5);
// Make the second column growable (the one with the text controls)
formSizer->AddGrowableCol(1, 1);
// Application name
formSizer->Add(new wxStaticText(this, wxID_ANY, _("Application name:")), 0, wxALL | wxALIGN_CENTER_VERTICAL, 5);
auto nameInput = new wxTextCtrl(this, wxID_ANY);
nameInput->SetValue(app->appName);
nameInput->SetHint(_("Example: Apple Music"));
nameInput->Bind(wxEVT_TEXT,
[this, app](wxCommandEvent& event) { app->appName = event.GetString().ToStdString(); });
formSizer->Add(nameInput, 1, wxALL | wxEXPAND, 5);
// Client ID
formSizer->Add(new wxStaticText(this, wxID_ANY, _("Discord client id:")), 0, wxALL | wxALIGN_CENTER_VERTICAL,
5);
auto clientIdInput = new wxTextCtrl(this, wxID_ANY);
clientIdInput->SetHint(_("Example: 1337188104829665340"));
clientIdInput->SetValue(app->clientId);
clientIdInput->Bind(wxEVT_TEXT,
[this, app](wxCommandEvent& event) { app->clientId = event.GetString().ToStdString(); });
formSizer->Add(clientIdInput, 1, wxALL | wxEXPAND, 5);
// Search endpoint
formSizer->Add(new wxStaticText(this, wxID_ANY, _("Search endpoint:")), 0, wxALL | wxALIGN_CENTER_VERTICAL, 5);
auto searchEndpointInput = new wxTextCtrl(this, wxID_ANY);
searchEndpointInput->SetValue(app->searchEndpoint);
searchEndpointInput->SetHint(_("Search endpoint: https://music.apple.com/search?term="));
searchEndpointInput->Bind(
wxEVT_TEXT, [this, app](wxCommandEvent& event) { app->searchEndpoint = event.GetString().ToStdString(); });
formSizer->Add(searchEndpointInput, 1, wxALL | wxEXPAND, 5);
// Dropdown
formSizer->Add(new wxStaticText(this, wxID_ANY, "Activity Type:"), 0, wxALL | wxALIGN_CENTER_VERTICAL, 5);
wxString choices[] = {_("Listening"), _("Watching"), _("Playing")};
wxChoice* activityChoice = new wxChoice(this, wxID_ANY, wxDefaultPosition, wxDefaultSize, 3, choices);
switch (app->type) {
case ActivityType::PLAYING:
activityChoice->SetSelection(2);
break;
case ActivityType::LISTENING:
activityChoice->SetSelection(0);
break;
case ActivityType::WATCHING:
activityChoice->SetSelection(1);
break;
default:
activityChoice->SetSelection(0);
}
activityChoice->Bind(wxEVT_CHOICE, [activityChoice, app](wxCommandEvent& event) {
const std::map<wxString, ActivityType> typeMap = {
{_("Listening"), ActivityType::LISTENING},
{_("Watching"), ActivityType::WATCHING},
{_("Playing"), ActivityType::PLAYING},
};
app->type = typeMap.at(event.GetString());
});
formSizer->Add(activityChoice, 1, wxALL | wxEXPAND, 5);
mainSizer->Add(formSizer, 0, wxEXPAND);
// Process names group
formSizer->Add(new wxStaticText(this, wxID_ANY, _("Process names:")), 0, wxALL | wxALIGN_CENTER_VERTICAL, 5);
wxBoxSizer* processBox = new wxBoxSizer(wxVERTICAL);
wxListBox* listBox = new wxListBox(this, wxID_ANY);
for (auto& process : app->processNames) {
listBox->Append(process);
}
if (app->processNames.size() == 0 && lastMediaSource != "") {
listBox->Append(lastMediaSource);
app->processNames.push_back(lastMediaSource);
}
processBox->Add(listBox, 1, wxALL | wxEXPAND, 5);
// Add input + buttons
wxBoxSizer* addSizer = new wxBoxSizer(wxHORIZONTAL);
auto processNameInput =
new wxTextCtrl(this, wxID_ANY, wxEmptyString, wxDefaultPosition, wxSize(250, wxDefaultSize.GetHeight()), 0);
processNameInput->SetHint(_("Process name"));
const auto delete_button_texture = utils::loadSettingsIcon(trash_svg, trash_svg_size);
const auto add_button_texture = utils::loadSettingsIcon(plus_svg, plus_svg_size);
wxBitmapButton* addButton = new wxBitmapButton(this, wxID_ANY, add_button_texture);
addButton->SetToolTip(_("Add process to list"));
wxBitmapButton* removeButton = new wxBitmapButton(this, wxID_ANY, delete_button_texture);
removeButton->SetToolTip(_("Remove process from list"));
addSizer->Add(processNameInput, 1, wxALL | wxEXPAND, 5);
addSizer->Add(addButton, 0, wxALL, 5);
addSizer->Add(removeButton, 0, wxALL, 5);
processBox->Add(addSizer, 0, wxEXPAND);
mainSizer->Add(processBox, 1, wxALL | wxEXPAND);
SetSizerAndFit(mainSizer);
Centre();
// Bind events
addButton->Bind(wxEVT_BUTTON, [processNameInput, listBox, app](wxCommandEvent& event) {
wxString name = processNameInput->GetValue().Trim();
if (!name.IsEmpty()) {
listBox->Append(name);
app->processNames.push_back(name.ToStdString());
processNameInput->Clear();
}
});
removeButton->Bind(wxEVT_BUTTON, [listBox, app](wxCommandEvent& event) {
int selection = listBox->GetSelection();
if (selection != wxNOT_FOUND) {
app->processNames.erase(std::find(app->processNames.begin(), app->processNames.end(),
listBox->GetString(selection).ToStdString()));
listBox->Delete(selection);
}
});
wxBoxSizer* buttonSizer = new wxBoxSizer(wxHORIZONTAL);
wxButton* okButton = new wxButton(this, wxID_OK, _("Save"));
wxButton* cancelButton = new wxButton(this, wxID_CANCEL, _("Cancel"));
buttonSizer->Add(okButton, 0, wxALL | wxALIGN_CENTER, 10);
buttonSizer->Add(cancelButton, 0, wxALL | wxALIGN_CENTER, 10);
mainSizer->Add(buttonSizer, 0, wxALL | wxALIGN_CENTER);
this->SetSizerAndFit(mainSizer);
this->CenterOnParent();
}
private:
void OnClose(wxCloseEvent& event) { EndModal(wxID_CANCEL); }
};
class PlayerLinkIcon : public wxTaskBarIcon {
public:
PlayerLinkIcon(wxFrame* s) : settingsFrame(s) {}
void OnMenuOpen(wxCommandEvent& evt) {
settingsFrame->Show(true);
settingsFrame->Raise();
}
void OnCopyOdesliURL(wxCommandEvent& evt) { utils::copyToClipboard(utils::getOdesliURL(songInfo)); }
void OnMenuExit(wxCommandEvent& evt) { settingsFrame->Close(true); }
void OnMenuAbout(wxCommandEvent& evt) {
wxMessageBox(_("Made with <3 by EinTim"), _("PlayerLink"), wxOK | wxICON_INFORMATION);
}
PlayerLinkIcon(wxFrame* s) : settingsFrame(s), aboutDlg(nullptr) {}
protected:
virtual wxMenu* CreatePopupMenu() override {
wxMenu* menu = new wxMenu;
menu->Append(10004, currentSongTitle == "" ? _("Not Playing") : wxString::FromUTF8(currentSongTitle));
menu->Append(10004, currentSongTitle == "" ? _("Not Playing") : currentSongTitle);
menu->Enable(10004, false);
menu->AppendSeparator();
menu->Append(10005, _("Copy Odesli URL"));
@ -189,115 +372,73 @@ protected:
}
private:
void OnMenuOpen(wxCommandEvent& evt) {
settingsFrame->Show(true);
settingsFrame->Raise();
}
void OnCopyOdesliURL(wxCommandEvent& evt) { utils::copyToClipboard(utils::getOdesliURL(songInfo)); }
void OnMenuExit(wxCommandEvent& evt) { settingsFrame->Close(true); }
void OnMenuAbout(wxCommandEvent& evt) {
aboutDlg.Show(true);
aboutDlg.Raise();
}
wxFrame* settingsFrame;
};
class wxTextCtrlWithPlaceholder : public wxTextCtrl {
public:
wxTextCtrlWithPlaceholder(wxWindow* parent, wxWindowID id, const wxString& value,
const wxPoint& pos = wxDefaultPosition, const wxSize& size = wxDefaultSize,
long style = 0, const wxValidator& validator = wxDefaultValidator,
const wxString& name = "textCtrl")
: wxTextCtrl(parent, id, value, pos, size, style, validator, name),
placeholder(""),
showPlaceholder(true),
isPassword((style & wxTE_PASSWORD) != 0) {
Bind(wxEVT_SET_FOCUS, &wxTextCtrlWithPlaceholder::OnFocus, this);
Bind(wxEVT_KILL_FOCUS, &wxTextCtrlWithPlaceholder::OnBlur, this);
}
void SetPlaceholderText(const wxString& p) {
placeholder = p;
if (GetValue().IsEmpty() || showPlaceholder)
UpdatePlaceholder();
}
protected:
void OnFocus(wxFocusEvent& event) {
if (showPlaceholder && GetValue() == placeholder) {
Clear();
if (isPassword)
SetStyleToPassword();
}
showPlaceholder = false;
event.Skip();
}
void OnBlur(wxFocusEvent& event) {
if (GetValue().IsEmpty()) {
showPlaceholder = true;
UpdatePlaceholder();
}
event.Skip();
}
private:
wxString placeholder;
bool showPlaceholder;
bool isPassword;
void UpdatePlaceholder() {
if (isPassword)
SetStyleToNormal();
SetValue(placeholder);
}
void SetStyleToPassword() { SetWindowStyle(GetWindowStyle() | wxTE_PASSWORD); }
void SetStyleToNormal() { SetWindowStyle(GetWindowStyle() & ~wxTE_PASSWORD); }
AboutDialog aboutDlg;
};
class PlayerLinkFrame : public wxFrame {
public:
PlayerLinkFrame(wxWindow* parent, wxIcon& icon, wxWindowID id = wxID_ANY, const wxString& title = wxEmptyString,
PlayerLinkFrame(wxWindow* parent, wxWindowID id = wxID_ANY, const wxString& title = wxEmptyString,
const wxPoint& pos = wxDefaultPosition, const wxSize& size = wxSize(300, 200),
long style = wxDEFAULT_FRAME_STYLE & ~wxRESIZE_BORDER & ~wxMAXIMIZE_BOX)
: wxFrame(parent, id, title, pos, size, style) {
this->SetSizeHints(wxDefaultSize, wxDefaultSize);
this->SetIcon(icon);
SetWindowIcon(this);
auto mainContainer = new wxBoxSizer(wxVERTICAL);
wxPanel* panel = new wxPanel(this, wxID_ANY, wxDefaultPosition, wxDefaultSize, wxTAB_TRAVERSAL);
panel->SetSizer(mainContainer);
wxBoxSizer* frameSizer = new wxBoxSizer(wxVERTICAL);
frameSizer->Add(panel, 1, wxEXPAND);
// header start
auto settingsText = new wxStaticText(this, wxID_ANY, _("Settings"), wxDefaultPosition, wxDefaultSize, 0);
auto settingsText = new wxStaticText(panel, wxID_ANY, _("Settings"), wxDefaultPosition, wxDefaultSize, 0);
settingsText->Wrap(-1);
mainContainer->Add(settingsText, 0, wxALIGN_CENTER | wxALL, 5);
// header end
// enabled apps start
auto settingsDivider = new wxStaticLine(this, wxID_ANY, wxDefaultPosition, wxDefaultSize, wxLI_HORIZONTAL);
auto settingsDivider = new wxStaticLine(panel, wxID_ANY, wxDefaultPosition, wxDefaultSize, wxLI_HORIZONTAL);
mainContainer->Add(settingsDivider, 0, wxEXPAND | wxALL, 5);
wxBoxSizer* enabledAppsContainer;
enabledAppsContainer = new wxBoxSizer(wxHORIZONTAL);
auto enabledAppsText =
new wxStaticText(this, wxID_ANY, _("Enabled Apps:"), wxDefaultPosition, wxDefaultSize, 0);
new wxStaticText(panel, wxID_ANY, _("Enabled Apps:"), wxDefaultPosition, wxDefaultSize, 0);
enabledAppsText->Wrap(-1);
enabledAppsContainer->Add(enabledAppsText, 0, wxALL, 5);
wxBoxSizer* appCheckboxContainer;
appCheckboxContainer = new wxBoxSizer(wxVERTICAL);
const auto edit_button_texture = utils::loadSettingsIcon(pencil_svg, pencil_svg_size);
const auto delete_button_texture = utils::loadSettingsIcon(trash_svg, trash_svg_size);
const auto add_button_texture = utils::loadSettingsIcon(plus_svg, plus_svg_size);
wxBoxSizer* appCheckboxContainer = new wxBoxSizer(wxVERTICAL);
wxBoxSizer* vSizer = new wxBoxSizer(wxVERTICAL);
enabledAppsContainer->Add(vSizer, 0, wxEXPAND);
auto settings = utils::getSettings();
for (auto app : settings.apps) {
auto checkbox = new wxCheckBox(this, wxID_ANY, app.appName, wxDefaultPosition, wxDefaultSize, 0);
checkbox->SetValue(app.enabled);
checkbox->SetClientData(new utils::App(app));
checkbox->Bind(wxEVT_CHECKBOX, [checkbox](wxCommandEvent& event) {
bool isChecked = checkbox->IsChecked();
utils::App* appData = static_cast<utils::App*>(checkbox->GetClientData());
appData->enabled = isChecked;
utils::saveSettings(appData);
});
checkbox->Bind(wxEVT_DESTROY, [checkbox](wxWindowDestroyEvent&) {
delete static_cast<utils::App*>(checkbox->GetClientData());
});
appCheckboxContainer->Add(checkbox, 0, wxALL, 5);
for (const auto& app : settings.apps) {
addCheckboxToContainer(panel, appCheckboxContainer, frameSizer, size, delete_button_texture,
edit_button_texture, app);
}
auto anyOtherCheckbox = new wxCheckBox(this, wxID_ANY, _("Any other"), wxDefaultPosition, wxDefaultSize, 0);
wxBoxSizer* checkboxRowSizer = new wxBoxSizer(wxHORIZONTAL);
auto anyOtherCheckbox = new wxCheckBox(panel, wxID_ANY, _("Any other"), wxDefaultPosition, wxDefaultSize, 0);
anyOtherCheckbox->SetValue(settings.anyOtherEnabled);
anyOtherCheckbox->Bind(wxEVT_CHECKBOX, [](wxCommandEvent& event) {
bool isChecked = event.IsChecked();
@ -306,28 +447,50 @@ public:
utils::saveSettings(settings);
});
appCheckboxContainer->Add(anyOtherCheckbox, 0, wxALL, 5);
wxBitmapButton* addButton = new wxBitmapButton(panel, wxID_ANY, add_button_texture);
addButton->SetToolTip(_("Add application"));
addButton->Bind(wxEVT_BUTTON, [this, panel, frameSizer, size, delete_button_texture, edit_button_texture,
appCheckboxContainer](wxCommandEvent& event) {
utils::App* app = new utils::App();
EditAppDialog dlg{this, _("Add new application"), app};
if (dlg.ShowModal() == wxID_OK) {
auto settings = utils::getSettings();
settings.apps.push_back(*app);
utils::saveSettings(settings);
addCheckboxToContainer(panel, appCheckboxContainer, frameSizer, size, delete_button_texture,
edit_button_texture, *app);
this->SetSizerAndFit(frameSizer);
wxSize currentSize = this->GetSize();
this->SetSize(size.GetWidth(), currentSize.GetHeight());
this->Layout();
} else
delete app;
});
enabledAppsContainer->Add(appCheckboxContainer, 1, wxEXPAND, 5);
checkboxRowSizer->Add(anyOtherCheckbox, 1, wxALL | wxALIGN_CENTER_VERTICAL);
checkboxRowSizer->Add(addButton, 0, wxLEFT | wxALIGN_CENTER_VERTICAL, 5);
vSizer->Add(appCheckboxContainer, 1, wxEXPAND, 5);
vSizer->Add(checkboxRowSizer, 1, wxALL, 5);
mainContainer->Add(enabledAppsContainer, 0, 0, 5);
// enabled apps end
// LastFM start
auto lastfmDivider = new wxStaticLine(this, wxID_ANY, wxDefaultPosition, wxDefaultSize, wxLI_HORIZONTAL);
auto lastfmDivider = new wxStaticLine(panel, wxID_ANY, wxDefaultPosition, wxDefaultSize, wxLI_HORIZONTAL);
mainContainer->Add(lastfmDivider, 0, wxEXPAND | wxALL, 5);
wxBoxSizer* lastFMContainer;
lastFMContainer = new wxBoxSizer(wxHORIZONTAL);
auto lastfmText = new wxStaticText(this, wxID_ANY, _("LastFM:"), wxDefaultPosition, wxDefaultSize, 0);
auto lastfmText = new wxStaticText(panel, wxID_ANY, _("LastFM:"), wxDefaultPosition, wxDefaultSize, 0);
lastfmText->Wrap(-1);
lastFMContainer->Add(lastfmText, 0, wxALL, 5);
wxBoxSizer* lastfmSettingsContainer;
lastfmSettingsContainer = new wxBoxSizer(wxVERTICAL);
auto lastfmEnabledCheckbox = new wxCheckBox(this, wxID_ANY, _("Enabled"), wxDefaultPosition, wxDefaultSize, 0);
auto lastfmEnabledCheckbox = new wxCheckBox(panel, wxID_ANY, _("Enabled"), wxDefaultPosition, wxDefaultSize, 0);
lastfmEnabledCheckbox->SetValue(settings.lastfm.enabled);
lastfmEnabledCheckbox->Bind(wxEVT_CHECKBOX, [](wxCommandEvent& event) {
bool isChecked = event.IsChecked();
@ -338,9 +501,8 @@ public:
lastfmSettingsContainer->Add(lastfmEnabledCheckbox, 0, wxALIGN_CENTER | wxALL, 5);
lastFMContainer->Add(lastfmSettingsContainer, 1, wxEXPAND, 5);
auto usernameInput =
new wxTextCtrlWithPlaceholder(this, wxID_ANY, wxEmptyString, wxDefaultPosition, wxDefaultSize, 0);
usernameInput->SetPlaceholderText(_("Username"));
auto usernameInput = new wxTextCtrl(panel, wxID_ANY, wxEmptyString, wxDefaultPosition, wxDefaultSize, 0);
usernameInput->SetHint(_("Username"));
usernameInput->SetValue(settings.lastfm.username);
usernameInput->Bind(wxEVT_TEXT, [this](wxCommandEvent& event) {
auto settings = utils::getSettings();
@ -348,9 +510,9 @@ public:
settings.lastfm.username = data;
utils::saveSettings(settings);
});
auto passwordInput = new wxTextCtrlWithPlaceholder(this, wxID_ANY, wxEmptyString, wxDefaultPosition,
wxDefaultSize, wxTE_PASSWORD);
passwordInput->SetPlaceholderText(_("Password"));
auto passwordInput =
new wxTextCtrl(panel, wxID_ANY, wxEmptyString, wxDefaultPosition, wxDefaultSize, wxTE_PASSWORD);
passwordInput->SetHint(_("Password"));
passwordInput->SetValue(settings.lastfm.password);
passwordInput->Bind(wxEVT_TEXT, [this](wxCommandEvent& event) {
auto settings = utils::getSettings();
@ -358,9 +520,8 @@ public:
settings.lastfm.password = data;
utils::saveSettings(settings);
});
auto apikeyInput =
new wxTextCtrlWithPlaceholder(this, wxID_ANY, wxEmptyString, wxDefaultPosition, wxDefaultSize, 0);
apikeyInput->SetPlaceholderText(_("API-Key"));
auto apikeyInput = new wxTextCtrl(panel, wxID_ANY, wxEmptyString, wxDefaultPosition, wxDefaultSize, 0);
apikeyInput->SetHint(_("API-Key"));
apikeyInput->SetValue(settings.lastfm.api_key);
apikeyInput->Bind(wxEVT_TEXT, [this](wxCommandEvent& event) {
auto settings = utils::getSettings();
@ -368,9 +529,9 @@ public:
settings.lastfm.api_key = data;
utils::saveSettings(settings);
});
auto apisecretInput = new wxTextCtrlWithPlaceholder(this, wxID_ANY, wxEmptyString, wxDefaultPosition,
wxDefaultSize, wxTE_PASSWORD);
apisecretInput->SetPlaceholderText(_("API-Secret"));
auto apisecretInput =
new wxTextCtrl(panel, wxID_ANY, wxEmptyString, wxDefaultPosition, wxDefaultSize, wxTE_PASSWORD);
apisecretInput->SetHint(_("API-Secret"));
apisecretInput->SetValue(settings.lastfm.api_secret);
apisecretInput->Bind(wxEVT_TEXT, [this](wxCommandEvent& event) {
auto settings = utils::getSettings();
@ -379,7 +540,7 @@ public:
utils::saveSettings(settings);
});
auto checkButton = new wxButton(this, wxID_ANY, _("Check credentials"));
auto checkButton = new wxButton(panel, wxID_ANY, _("Check credentials"));
checkButton->Bind(wxEVT_BUTTON, [this](wxCommandEvent& event) { initLastFM(true); });
mainContainer->Add(lastFMContainer, 0, 0, 5);
@ -392,7 +553,7 @@ public:
// Last FM End
// settings start
auto appsDivider = new wxStaticLine(this, wxID_ANY, wxDefaultPosition, wxDefaultSize, wxLI_HORIZONTAL);
auto appsDivider = new wxStaticLine(panel, wxID_ANY, wxDefaultPosition, wxDefaultSize, wxLI_HORIZONTAL);
mainContainer->Add(appsDivider, 0, wxEXPAND | wxALL, 5);
wxBoxSizer* settingsContainer;
@ -401,12 +562,12 @@ public:
wxBoxSizer* startupContainer;
startupContainer = new wxBoxSizer(wxHORIZONTAL);
auto startupText = new wxStaticText(this, wxID_ANY, _("Startup:"), wxDefaultPosition, wxDefaultSize, 0);
auto startupText = new wxStaticText(panel, wxID_ANY, _("Startup:"), wxDefaultPosition, wxDefaultSize, 0);
startupText->Wrap(-1);
startupContainer->Add(startupText, 0, wxALL, 5);
auto autostartCheckbox =
new wxCheckBox(this, wxID_ANY, _("Launch at login"), wxDefaultPosition, wxDefaultSize, 0);
new wxCheckBox(panel, wxID_ANY, _("Launch at login"), wxDefaultPosition, wxDefaultSize, 0);
autostartCheckbox->SetValue(settings.autoStart);
autostartCheckbox->Bind(wxEVT_CHECKBOX, [](wxCommandEvent& event) {
bool isChecked = event.IsChecked();
@ -417,7 +578,7 @@ public:
});
auto odesliCheckbox =
new wxCheckBox(this, wxID_ANY, _("Odesli integration"), wxDefaultPosition, wxDefaultSize, 0);
new wxCheckBox(panel, wxID_ANY, _("Odesli integration"), wxDefaultPosition, wxDefaultSize, 0);
odesliCheckbox->SetValue(settings.odesli);
odesliCheckbox->Bind(wxEVT_CHECKBOX, [](wxCommandEvent& event) {
bool isChecked = event.IsChecked();
@ -430,17 +591,80 @@ public:
settingsContainer->Add(odesliCheckbox, 0, wxALL, 5);
startupContainer->Add(settingsContainer);
mainContainer->Add(startupContainer, 0, wxEXPAND, 5);
// settings end
this->SetSizerAndFit(mainContainer);
this->SetSizerAndFit(frameSizer);
wxSize currentSize = this->GetSize();
this->SetSize(size.GetWidth(), currentSize.GetHeight());
this->Layout();
this->Centre(wxBOTH);
panel->SetFocus();
}
private:
void addCheckboxToContainer(wxPanel* panel, wxBoxSizer* container, wxBoxSizer* frameSizer, const wxSize& size,
const wxBitmap& delete_button_texture, const wxBitmap& edit_button_texture,
const utils::App& app) {
wxBoxSizer* checkboxRowSizer = new wxBoxSizer(wxHORIZONTAL);
auto checkbox = new wxCheckBox(panel, wxID_ANY, app.appName, wxDefaultPosition, wxDefaultSize, 0);
checkbox->SetValue(app.enabled);
checkbox->SetClientData(new utils::App(app));
checkbox->Bind(wxEVT_CHECKBOX, [checkbox](wxCommandEvent& event) {
bool isChecked = checkbox->IsChecked();
utils::App* appData = static_cast<utils::App*>(checkbox->GetClientData());
appData->enabled = isChecked;
utils::saveSettings(appData);
});
checkbox->Bind(wxEVT_DESTROY, [checkbox](wxWindowDestroyEvent&) {
delete static_cast<utils::App*>(checkbox->GetClientData());
});
wxBitmapButton* editButton = new wxBitmapButton(panel, wxID_ANY, edit_button_texture);
editButton->SetToolTip(_("Edit application"));
editButton->Bind(wxEVT_BUTTON, [this, checkbox](wxCommandEvent& event) {
utils::App* appData = static_cast<utils::App*>(checkbox->GetClientData());
utils::App backupApp = *appData;
EditAppDialog dlg{this, _("Edit application") + " " + appData->appName, appData};
if (dlg.ShowModal() == wxID_OK) {
auto settings = utils::getSettings();
for (auto& app : settings.apps) {
if (app == backupApp)
app = *appData;
}
utils::saveSettings(settings);
checkbox->SetLabelText(appData->appName);
this->Layout();
}
});
wxBitmapButton* deleteButton = new wxBitmapButton(panel, wxID_ANY, delete_button_texture);
deleteButton->SetToolTip(_("Delete application"));
deleteButton->Bind(wxEVT_BUTTON,
[this, checkboxRowSizer, container, frameSizer, checkbox, size](wxCommandEvent& event) {
utils::App* appData = static_cast<utils::App*>(checkbox->GetClientData());
auto settings = utils::getSettings();
settings.apps.erase(std::find(settings.apps.begin(), settings.apps.end(), *appData));
utils::saveSettings(settings);
container->Detach(checkboxRowSizer);
this->CallAfter([this, checkboxRowSizer, frameSizer, size]() {
checkboxRowSizer->Clear(true);
this->SetSizerAndFit(frameSizer);
wxSize currentSize = this->GetSize();
this->SetSize(size.GetWidth(), currentSize.GetHeight());
this->Layout();
});
});
checkboxRowSizer->Add(checkbox, 1, wxALL | wxALIGN_CENTER_VERTICAL);
checkboxRowSizer->Add(editButton, 0, wxLEFT | wxALIGN_CENTER_VERTICAL, 5);
checkboxRowSizer->Add(deleteButton, 0, wxLEFT | wxALIGN_CENTER_VERTICAL, 5);
container->Add(checkboxRowSizer, 0, wxALL, 5);
}
};
class PlayerLink : public wxApp {
public:
virtual bool OnInit() override {
@ -453,8 +677,8 @@ public:
this->SetAppearance(wxAppBase::Appearance::Dark);
wxInitAllImageHandlers();
wxIcon icon = utils::loadIconFromMemory(icon_png, icon_png_size);
PlayerLinkFrame* frame = new PlayerLinkFrame(nullptr, icon, wxID_ANY, _("PlayerLink"));
wxIcon tray_icon = utils::loadIconFromMemory(menubar_icon_png, menubar_icon_png_size);
PlayerLinkFrame* frame = new PlayerLinkFrame(nullptr, wxID_ANY, _("PlayerLink"));
trayIcon = new PlayerLinkIcon(frame);
frame->Bind(wxEVT_CLOSE_WINDOW, [=](wxCloseEvent& event) {
if (event.CanVeto()) {
@ -464,7 +688,7 @@ public:
std::exit(0);
});
trayIcon->SetIcon(icon, _("PlayerLink"));
trayIcon->SetIcon(tray_icon, _("PlayerLink"));
return true;
}

View File

@ -4,7 +4,6 @@
#include <wx/mstream.h>
#include <wx/wx.h>
#include <wx/clipbrd.h>
#include <filesystem>
#include <fstream>
#include <nlohmann-json/single_include/nlohmann/json.hpp>
@ -21,10 +20,14 @@
namespace utils {
struct App {
bool enabled;
int type;
std::string appName;
std::string clientId;
std::string searchEndpoint;
std::vector<std::string> processNames;
bool operator==(const App& other) const {
return appName == other.appName && clientId == other.clientId && type == other.type;
}
};
struct LastFMSettings {
@ -56,18 +59,57 @@ namespace utils {
}
}
inline wxIcon loadIconFromMemory(const unsigned char* data, size_t size) {
inline wxBitmap loadImageFromMemory(const unsigned char* data, size_t size, int width = 0, int height = 0) {
wxMemoryInputStream stream(data, size);
wxImage img(stream, wxBITMAP_TYPE_PNG);
if (img.IsOk()) {
if (width != 0 || height != 0)
img.Rescale(width, height, wxIMAGE_QUALITY_HIGH);
wxBitmap bmp(img);
wxIcon icon;
icon.CopyFromBitmap(bmp);
return icon;
return bmp;
}
return wxNullIcon;
return wxNullBitmap;
}
inline wxIcon loadIconFromMemory(const unsigned char* data, size_t size, int width = 0, int height = 0) {
wxIcon icn{};
icn.CopyFromBitmap(loadImageFromMemory(data, size, width, height));
return icn;
}
inline wxBitmap loadColoredSVG(const unsigned char* svg, const unsigned int svg_size, const wxSize& size,
const wxColour& color) {
const std::string defaultColor = "currentColor";
std::string svg_data = std::string((const char*)svg, svg_size);
size_t start_pos = svg_data.find(defaultColor);
if (start_pos != std::string::npos)
svg_data.replace(start_pos, defaultColor.length(), color.GetAsString(wxC2S_HTML_SYNTAX));
wxBitmapBundle bundle = wxBitmapBundle::FromSVG(svg_data.c_str(), size);
if (!bundle.IsOk())
return wxNullBitmap;
wxBitmap bmp = bundle.GetBitmap(size);
if (!bmp.IsOk())
return wxNullBitmap;
return bmp;
}
inline wxBitmap loadSettingsIcon(const unsigned char* svg, const unsigned int svg_size,
const wxSize& size = wxSize(16, 16)) {
return loadColoredSVG(
svg, svg_size, size,
wxSystemSettings::GetAppearance().IsSystemDark() ? wxColor(255, 255, 255, 255) : wxColor(0, 0, 0, 255));
}
inline std::string toLower(const std::string& str) {
std::string lowerStr = str;
std::transform(lowerStr.begin(), lowerStr.end(), lowerStr.begin(),
[](unsigned char c) { return std::tolower(c); });
return lowerStr;
}
inline bool caseInsensitiveMatch(const std::string& a, const std::string& b) { return toLower(a) == toLower(b); }
inline std::string ltrim(std::string& s) {
s.erase(s.begin(), std::find_if(s.begin(), s.end(), [](unsigned char ch) { return !std::isspace(ch); }));
return s;
@ -144,19 +186,23 @@ namespace utils {
SongInfo ret{};
std::string response =
httpRequest("https://itunes.apple.com/search?media=music&entity=song&term=" + urlEncode(query));
nlohmann::json j = nlohmann::json::parse(response);
auto results = j["results"];
if (results.size() > 0) {
ret.artworkURL = results[0]["artworkUrl100"].get<std::string>();
ret.trackId = results[0]["trackId"].get<int64_t>();
try {
nlohmann::json j = nlohmann::json::parse(response);
auto results = j["results"];
if (results.size() > 0) {
ret.artworkURL = results[0]["artworkUrl100"].get<std::string>();
ret.trackId = results[0]["trackId"].get<int64_t>();
}
return ret;
} catch (...) {
return ret;
}
return ret;
}
inline std::string getOdesliURL(SongInfo& song) {
return std::string("https://song.link/i/" + std::to_string(song.trackId));
}
inline void saveSettings(const App* newApp) {
nlohmann::json j;
@ -224,7 +270,7 @@ namespace utils {
appJson["client_id"] = app.clientId;
appJson["search_endpoint"] = app.searchEndpoint;
appJson["enabled"] = app.enabled;
appJson["type"] = app.type;
for (const auto& processName : app.processNames) appJson["process_names"].push_back(processName);
j["apps"].push_back(appJson);
@ -269,8 +315,9 @@ namespace utils {
a.clientId = app.value("client_id", "");
a.searchEndpoint = app.value("search_endpoint", "");
a.enabled = app.value("enabled", false);
a.type = app.value("type", 2);
for (const auto& process : app["process_names"]) a.processNames.push_back(process.get<std::string>());
for (const auto& process : app.value("process_names", nlohmann::json())) a.processNames.push_back(process.get<std::string>());
ret.apps.push_back(a);
}
@ -283,7 +330,7 @@ namespace utils {
auto settings = getSettings();
for (auto app : settings.apps) {
for (auto procName : app.processNames) {
if (procName == processName)
if (caseInsensitiveMatch(procName, processName))
return app;
}
}
@ -291,6 +338,7 @@ namespace utils {
a.clientId = DEFAULT_CLIENT_ID;
a.appName = DEFAULT_APP_NAME;
a.enabled = settings.anyOtherEnabled;
a.type = 2; // Default to listening
a.searchEndpoint = "";
return a;
}

View File

@ -19,6 +19,8 @@ set(wxBUILD_SHARED OFF)
set(wxBUILD_MONOLITHIC ON)
set(wxUSE_GUI ON)
set(wxUSE_WEBVIEW OFF)
set(wxUSE_UNICODE_UTF8 ON)
set(wxUSE_UTF8_LOCALE_ONLY ON)
add_subdirectory("wxWidgets")
if(UNIX AND NOT APPLE)
add_subdirectory("dbus")

2
vendor/discord-rpc vendored

@ -1 +1 @@
Subproject commit e86d7a81de7a33323e2038182ab53a26c69f7880
Subproject commit 6bc42071f109083178ae824898ca4b3cb40a8307

2
vendor/wxWidgets vendored

@ -1 +1 @@
Subproject commit 138937b7775c117b57f55374a0c507a35a1102f6
Subproject commit d7a696de4c301948f02dc5d7979f867f07f9d684

Binary file not shown.

Before

Width:  |  Height:  |  Size: 260 KiB

After

Width:  |  Height:  |  Size: 156 KiB