Skip to content

Commit 3e4fd6f

Browse files
committed
Shortcut commands, fixed playlist loading, improved settings menu
1 parent 102da75 commit 3e4fd6f

32 files changed

Lines changed: 1643 additions & 231 deletions

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "just-another-music-client",
33
"private": true,
4-
"version": "1.2.7",
4+
"version": "1.2.71",
55
"type": "module",
66
"scripts": {
77
"dev": "vite",

src-tauri/Cargo.lock

Lines changed: 4 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src-tauri/Cargo.toml

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "just-another-music-client"
3-
version = "1.2.7"
3+
version = "1.2.71"
44
description = "A desktop music client"
55
authors = ["you"]
66
edition = "2021"
@@ -35,6 +35,9 @@ tauri-plugin-process = "2"
3535

3636
[target.'cfg(target_os = "macos")'.dependencies]
3737
aes-gcm = "0.10"
38+
block2 = "0.6.2"
39+
objc2 = "0.6.4"
40+
objc2-foundation = { version = "0.3.2", features = ["NSDictionary", "NSObject", "NSString", "NSValue"] }
3841
rand = "0.8"
3942

4043
[target.'cfg(target_os = "windows")'.dependencies]

src-tauri/capabilities/default.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@
5757
"fetch_audio_bytes",
5858
"fetch_youtube_music_audio",
5959
"proxy_http_request",
60+
"update_macos_media_session",
6061
"update_windows_media_session",
6162
"save_youtube_credentials",
6263
"load_youtube_credentials",

src-tauri/src/lib.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,8 @@ use rand::{rngs::OsRng, RngCore};
3838

3939
#[cfg(target_os = "windows")]
4040
mod windows_media;
41+
#[cfg(target_os = "macos")]
42+
mod macos_media;
4143

4244
mod discord_rpc;
4345

@@ -1884,6 +1886,8 @@ pub fn run() {
18841886

18851887
#[cfg(target_os = "windows")]
18861888
let builder = builder.manage(windows_media::WindowsMediaSession::new());
1889+
#[cfg(target_os = "macos")]
1890+
let builder = builder.manage(macos_media::MacosMediaSession::new());
18871891

18881892
builder
18891893
.setup(|app| {
@@ -1957,6 +1961,8 @@ pub fn run() {
19571961
cache_clear,
19581962
discord_rpc_update,
19591963
discord_rpc_clear,
1964+
#[cfg(target_os = "macos")]
1965+
macos_media::update_macos_media_session,
19601966
#[cfg(target_os = "windows")]
19611967
windows_media::update_windows_media_session
19621968
])

src-tauri/src/macos_media.rs

Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,191 @@
1+
use block2::RcBlock;
2+
use objc2::rc::Retained;
3+
use objc2::runtime::{AnyClass, AnyObject};
4+
use objc2::{class, msg_send};
5+
use objc2_foundation::{NSMutableDictionary, NSNumber, NSString};
6+
use serde::Deserialize;
7+
use std::sync::Mutex;
8+
use tauri::{AppHandle, Emitter};
9+
10+
const MEDIA_CONTROL_EVENT: &str = "macos-media-control";
11+
const COMMAND_SUCCESS: isize = 0;
12+
13+
#[derive(Deserialize)]
14+
#[serde(rename_all = "camelCase")]
15+
pub struct MediaSessionUpdate {
16+
title: Option<String>,
17+
artist: Option<String>,
18+
artwork_url: Option<String>,
19+
status: String,
20+
duration_sec: Option<f64>,
21+
position_sec: Option<f64>,
22+
}
23+
24+
pub struct MacosMediaSession(Mutex<bool>);
25+
26+
unsafe impl Send for MacosMediaSession {}
27+
unsafe impl Sync for MacosMediaSession {}
28+
29+
#[link(name = "MediaPlayer", kind = "framework")]
30+
extern "C" {
31+
static MPMediaItemPropertyTitle: *const NSString;
32+
static MPMediaItemPropertyArtist: *const NSString;
33+
static MPMediaItemPropertyPlaybackDuration: *const NSString;
34+
static MPNowPlayingInfoPropertyElapsedPlaybackTime: *const NSString;
35+
static MPNowPlayingInfoPropertyPlaybackRate: *const NSString;
36+
}
37+
38+
impl MacosMediaSession {
39+
pub fn new() -> Self {
40+
Self(Mutex::new(false))
41+
}
42+
43+
fn ensure_handlers(&self, app: &AppHandle) -> Result<(), String> {
44+
let mut initialized = self.0.lock().map_err(|error| error.to_string())?;
45+
if *initialized {
46+
return Ok(());
47+
}
48+
49+
install_remote_command_handler(app, "playCommand", "play")?;
50+
install_remote_command_handler(app, "pauseCommand", "pause")?;
51+
install_remote_command_handler(app, "nextTrackCommand", "next")?;
52+
install_remote_command_handler(app, "previousTrackCommand", "previous")?;
53+
*initialized = true;
54+
Ok(())
55+
}
56+
57+
fn update(&self, app: &AppHandle, update: MediaSessionUpdate) -> Result<(), String> {
58+
self.ensure_handlers(app)?;
59+
set_now_playing_info(update);
60+
Ok(())
61+
}
62+
}
63+
64+
fn command_center() -> *mut AnyObject {
65+
let class: &AnyClass = class!(MPRemoteCommandCenter);
66+
unsafe { msg_send![class, sharedCommandCenter] }
67+
}
68+
69+
fn now_playing_info_center() -> *mut AnyObject {
70+
let class: &AnyClass = class!(MPNowPlayingInfoCenter);
71+
unsafe { msg_send![class, defaultCenter] }
72+
}
73+
74+
fn install_remote_command_handler(
75+
app: &AppHandle,
76+
command_selector: &str,
77+
action: &'static str,
78+
) -> Result<(), String> {
79+
let center = command_center();
80+
if center.is_null() {
81+
return Err("macOS remote command center unavailable".to_string());
82+
}
83+
84+
let command: *mut AnyObject = unsafe {
85+
match command_selector {
86+
"playCommand" => msg_send![center, playCommand],
87+
"pauseCommand" => msg_send![center, pauseCommand],
88+
"nextTrackCommand" => msg_send![center, nextTrackCommand],
89+
"previousTrackCommand" => msg_send![center, previousTrackCommand],
90+
_ => return Err(format!("unsupported macOS media command: {command_selector}")),
91+
}
92+
};
93+
94+
if command.is_null() {
95+
return Err(format!("macOS media command unavailable: {command_selector}"));
96+
}
97+
98+
let app = app.clone();
99+
let block = RcBlock::new(move |_event: *mut AnyObject| {
100+
let _ = app.emit(MEDIA_CONTROL_EVENT, action);
101+
COMMAND_SUCCESS
102+
});
103+
104+
unsafe {
105+
let _: () = msg_send![command, setEnabled: true];
106+
let _: *mut AnyObject = msg_send![command, addTargetWithHandler: &*block];
107+
}
108+
109+
std::mem::forget(block);
110+
Ok(())
111+
}
112+
113+
fn set_now_playing_info(update: MediaSessionUpdate) {
114+
let center = now_playing_info_center();
115+
if center.is_null() {
116+
return;
117+
}
118+
119+
let Some(title) = update.title else {
120+
let nil_info: *mut AnyObject = std::ptr::null_mut();
121+
unsafe {
122+
let _: () = msg_send![center, setNowPlayingInfo: nil_info];
123+
}
124+
return;
125+
};
126+
127+
let info = NSMutableDictionary::<NSString, AnyObject>::dictionaryWithCapacity(6);
128+
insert_string(&info, unsafe { &*MPMediaItemPropertyTitle }, &title);
129+
130+
if let Some(artist) = update.artist {
131+
insert_string(&info, unsafe { &*MPMediaItemPropertyArtist }, &artist);
132+
}
133+
134+
if let Some(duration) = update.duration_sec.filter(|duration| duration.is_finite()) {
135+
insert_number(
136+
&info,
137+
unsafe { &*MPMediaItemPropertyPlaybackDuration },
138+
duration.max(0.0),
139+
);
140+
}
141+
142+
if let Some(position) = update.position_sec.filter(|position| position.is_finite()) {
143+
insert_number(
144+
&info,
145+
unsafe { &*MPNowPlayingInfoPropertyElapsedPlaybackTime },
146+
position.max(0.0),
147+
);
148+
}
149+
150+
let playback_rate = if update.status == "playing" { 1.0 } else { 0.0 };
151+
insert_number(
152+
&info,
153+
unsafe { &*MPNowPlayingInfoPropertyPlaybackRate },
154+
playback_rate,
155+
);
156+
157+
let _ = update.artwork_url;
158+
159+
unsafe {
160+
let _: () = msg_send![center, setNowPlayingInfo: &*info];
161+
}
162+
}
163+
164+
fn insert_string(
165+
info: &NSMutableDictionary<NSString, AnyObject>,
166+
key: &NSString,
167+
value: &str,
168+
) {
169+
let value = NSString::from_str(value);
170+
let value: Retained<AnyObject> = value.into();
171+
info.insert(key, &value);
172+
}
173+
174+
fn insert_number(
175+
info: &NSMutableDictionary<NSString, AnyObject>,
176+
key: &NSString,
177+
value: f64,
178+
) {
179+
let value = NSNumber::new_f64(value);
180+
let value: Retained<AnyObject> = value.into();
181+
info.insert(key, &value);
182+
}
183+
184+
#[tauri::command]
185+
pub fn update_macos_media_session(
186+
app: AppHandle,
187+
state: tauri::State<'_, MacosMediaSession>,
188+
update: MediaSessionUpdate,
189+
) -> Result<(), String> {
190+
state.update(&app, update)
191+
}

src-tauri/tauri.conf.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"$schema": "https://schema.tauri.app/config/2",
33
"productName": "Just Another Music Client",
4-
"version": "1.2.7",
4+
"version": "1.2.71",
55
"identifier": "com.justanothermusicclient.desktop",
66
"build": {
77
"beforeDevCommand": "npm run dev",

src/datasource/DataSource.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import type {
66
Lyrics,
77
Playlist,
88
SearchResults,
9+
TrackPage,
910
Track,
1011
} from "./types";
1112

@@ -31,6 +32,11 @@ export abstract class DataSource {
3132
getArtist?(artistId: string, onUpdate?: (artist: ArtistPage) => void): Promise<ArtistPage>;
3233
setArtistSubscribed?(artistId: string, subscribed: boolean): Promise<void>;
3334
getPlaylistTracks?(playlist: Playlist, onUpdate?: (tracks: Track[]) => void): Promise<Track[]>;
35+
getPlaylistTrackPage?(
36+
playlist: Playlist,
37+
pageKey?: string,
38+
onUpdate?: (page: TrackPage) => void,
39+
): Promise<TrackPage>;
3440
setPlaylistSaved?(playlist: Playlist, saved: boolean): Promise<void>;
3541
addTrackToPlaylist?(
3642
track: Track,

src/datasource/types.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,12 @@ export interface SearchResults {
7474
playlists: Playlist[];
7575
}
7676

77+
export interface TrackPage {
78+
tracks: Track[];
79+
nextPageKey?: string;
80+
hasMore: boolean;
81+
}
82+
7783
export interface AuthPrompt {
7884
verificationUrl: string;
7985
userCode: string;

0 commit comments

Comments
 (0)