Compare commits

...

90 commits
int ... trunk

Author SHA1 Message Date
f8015fbe10 seekbar also works again 2024-10-30 13:25:27 +01:00
bf8b02de12 volume slider works again 2024-10-30 13:09:22 +01:00
0f2351ae01 make the play pause button work again 2024-10-30 12:59:16 +01:00
6f3b3537ad wip translated ui playbar class 2024-10-30 12:45:50 +01:00
b94d41542f wrap playbar 2024-10-30 11:42:03 +01:00
07163eb4f3 add cargo-doc target 2024-10-30 10:06:48 +01:00
c98290d4f0 wrap some classes and a struct 2024-10-30 10:06:10 +01:00
427e42a087 remove useless include 2024-10-30 10:01:14 +01:00
4ee544a88d try better meson_config.rs handling 2024-10-30 09:05:10 +01:00
ec2167f8fe just use build time bindgen for mpv 2024-10-30 09:01:45 +01:00
66467ee8af clippey 2024-10-30 08:52:48 +01:00
bb097cd3d5 generate header for vala lib 2024-10-30 08:48:30 +01:00
4ca473eb11 mpv oopsie 2024-10-30 08:44:59 +01:00
e02bc10ebc partial mpv bindings 2024-10-30 08:41:25 +01:00
77cb665da8 grr 2024-10-30 08:41:25 +01:00
6def98af81 add gettext init to rs 2024-10-30 07:33:52 +01:00
59eba426d2 meson fmt 2024-10-30 04:28:11 +01:00
a8453e6e3d clippy idiomacy 2024-10-30 04:26:53 +01:00
879dc39c27 ninja -C build clippy 2024-10-30 04:24:36 +01:00
9e5d23061c more meson stuff 2024-10-29 19:45:10 +01:00
38f1ce63d3 shoo 2024-10-29 19:27:13 +01:00
2bba51b97b meson stuff 2024-10-29 19:14:04 +01:00
2f70f61547 don' use the libsecret crate 2024-10-29 19:13:58 +01:00
1f289ecf1e gir 2024-10-29 15:46:33 +01:00
4193fb72c2 translated src/application.vala 2024-10-29 14:02:29 +01:00
5bfaeade17 bring back vala for a sec 2024-10-29 13:19:12 +01:00
5e2d2807c1 translate src/main.c 2024-10-29 13:02:18 +01:00
36b2cf989d cargo fmt, and also pass argc/argv to c lib 2024-10-29 12:29:04 +01:00
66500f483c let cargo build the resources 2024-10-29 11:56:44 +01:00
824a68956a this is not a place of honor 2024-10-29 11:37:15 +01:00
1ec9d1c2eb commence the ritual 2024-10-29 10:48:40 +01:00
68d1d78216 some restructuring 2024-10-28 11:02:13 +01:00
7799566758 expose play queue length as playbin property 2024-10-28 10:26:12 +01:00
e925dc33cc "fix" play button 2024-10-28 10:22:42 +01:00
a5091d42e0 add cover art member to song class 2024-10-28 10:16:15 +01:00
dfe4a24c5b turn playbin.play_queue_position into an int 2024-10-28 10:03:45 +01:00
713b3d8842 use adw box for more things 2024-10-28 09:52:50 +01:00
edaf7079ac add dropdown to album view 2024-10-28 09:47:27 +01:00
3f7f7d4ce9 feishinization continues 2024-10-27 22:01:49 +01:00
77f9d70d29 feishinize more the play queue
and also readd the drag widget i guess
2024-10-27 21:39:29 +01:00
6a2b157c7d new play queue style 2024-10-27 12:20:43 +01:00
f240424774 allow play to be pressed if stopped but playlist not empty 2024-10-26 17:46:15 +02:00
a82b5b0475 fix edge case when stopping and then selecting something 2024-10-26 17:30:58 +02:00
175563ba76 mpris: don't warn when api is set 2024-10-26 15:22:53 +02:00
8e5ac49417 cover art for mpris 2024-10-26 11:38:08 +02:00
347cb55c9d more mpris 2024-10-26 11:34:12 +02:00
efc639367b clear mpris metadata on stop 2024-10-26 11:28:36 +02:00
3772be599b mpris support 2024-10-26 11:26:55 +02:00
8c4c4f8e74 make mpv call its audio output audrey 2024-10-26 09:26:17 +02:00
36d6734e73 stop playback immediately on window close 2024-10-26 09:20:25 +02:00
d550d8f9b7 that was never called 2024-10-26 09:13:39 +02:00
faa5d15e1e add warning 2024-10-26 09:11:58 +02:00
9f6bc7b10b Revert "use pipe for mpv wakeup callback"
This reverts commit 72d4e63249.

more trouble than it's worth
2024-10-26 09:10:55 +02:00
d666c8fb49 whoopsies 2024-10-26 00:12:11 +02:00
41b32aa4cf mpv event id to string 2024-10-26 00:05:56 +02:00
64dcceea22 debug destructors 2024-10-26 00:00:20 +02:00
72d4e63249 use pipe for mpv wakeup callback
hopefully works better with the gtk main loop but idk lol
2024-10-25 23:26:45 +02:00
2b2ace0f5c some async 2024-10-25 22:09:57 +02:00
f436557bf5 add "no songs queued" placeholder 2024-10-25 11:05:01 +02:00
fdd719f4f8 baahhhhh 2024-10-20 18:25:40 +02:00
48795a4d2f fix some warnings 2024-10-20 17:46:59 +02:00
41bfab9ab8 bring back position in play queue 2024-10-20 17:44:16 +02:00
a556fe7e29 placeholder albums nav page 2024-10-20 17:43:06 +02:00
af4da894fc only show cover art in playbar if view isnt play queue 2024-10-20 17:28:47 +02:00
fa9d3b873f add placeholder options to song popover 2024-10-20 16:47:35 +02:00
2f96d2216d new art sizing 2024-10-20 16:25:30 +02:00
83b2db4b76 play queue experiment 2024-10-20 16:15:22 +02:00
75cb222b91 kill sidebar 2024-10-20 15:30:22 +02:00
734ffc5758 try fix duration fallback 2024-10-20 14:45:37 +02:00
9ba12bf365 default to no position shown in play queue 2024-10-20 14:26:51 +02:00
c6446f4352 fancy fancy drag widget :o 2024-10-20 14:24:04 +02:00
b53801c470 track reordering!!! 2024-10-20 14:10:02 +02:00
64744819de reimplement track deletion 2024-10-20 13:45:47 +02:00
f09a89140d dont seek if the playbar doesnt have a duration 2024-10-20 13:37:33 +02:00
d2025102e6 warn on failed seek 2024-10-20 13:35:15 +02:00
26449b9dcf this should be better than inc_position 2024-10-20 13:32:15 +02:00
5c24cde637 fix end of stream lol 2024-10-20 13:26:36 +02:00
af127b8d7b yet another playbin refactor 2024-10-20 13:17:40 +02:00
c880729d19 remove bottom sheet for now 2024-10-20 12:11:32 +02:00
0fd5298a31 better cover art loading 2024-10-20 12:05:48 +02:00
f97b8ab17c fix playbar centering 2024-10-20 12:03:24 +02:00
35394d74ac refactor 2024-10-20 12:02:06 +02:00
eec61c8ed0 configure play queue displayed fields 2024-10-20 11:57:47 +02:00
c8e4e55410 silence blueprint suggestion 2024-10-20 11:52:51 +02:00
4c9f24cd6b silence adwaita warning 2024-10-20 11:52:09 +02:00
f78268c013 rm name
replace with the hamburger about menu later
2024-10-20 09:18:05 +02:00
73a626e5a0 dbg 2024-10-20 08:33:25 +02:00
5214daf4e7 shorter 2024-10-20 08:32:56 +02:00
800bff6e1a fix keyring unlock
closes #5
2024-10-20 08:31:20 +02:00
cc525dcc80 split playbar into its own template 2024-10-19 17:04:56 +02:00
50 changed files with 3452 additions and 810 deletions

1
.gitignore vendored Normal file
View file

@ -0,0 +1 @@
target/

1034
Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

16
Cargo.toml Normal file
View file

@ -0,0 +1,16 @@
[package]
name = "audrey"
version = "0.1.0"
edition = "2021"
[dependencies]
adw = { version = "0.7.0", package = "libadwaita", features = ["v1_6"] }
gettext-rs = { version = "0.7.2", features = ["gettext-system"] }
gtk = { version = "0.9.2", package = "gtk4", features = ["v4_16"] }
[build-dependencies]
bindgen = "0.70.1"
glib-build-tools = "0.20.0"
[profile.dev]
split-debuginfo = "unpacked"

21
build.rs Normal file
View file

@ -0,0 +1,21 @@
fn main() {
let meson_build_root =
std::env::var("MESON_BUILD_ROOT").expect("build this through meson please!!");
glib_build_tools::compile_resources(
&["resources", &format!("{meson_build_root}/resources")],
"resources/audrey.gresource.xml",
"audrey.gresource",
);
// TODO: consider using meson to pass include paths
println!("cargo:rustc-link-lib=mpv");
let bindings = bindgen::Builder::default()
.header("src/mpv/wrapper.h")
.parse_callbacks(Box::new(bindgen::CargoCallbacks::new()))
.generate()
.expect("could not generate bindings for mpv");
let out_path = std::path::PathBuf::from(std::env::var("OUT_DIR").unwrap());
bindings
.write_to_file(out_path.join("mpv_ffi.rs"))
.expect("could not write mpv bindings");
}

View file

@ -8,10 +8,10 @@ project(
i18n = import('i18n') i18n = import('i18n')
gnome = import('gnome') gnome = import('gnome')
fs = import('fs')
cc = meson.get_compiler('c') cc = meson.get_compiler('c')
valac = meson.get_compiler('vala') valac = meson.get_compiler('vala')
srcdir = meson.project_source_root() / 'src' srcdir = meson.project_source_root() / 'src'
config_h = configuration_data() config_h = configuration_data()
@ -38,13 +38,48 @@ add_project_arguments(
language: 'c', language: 'c',
) )
subdir('data') subdir('data')
subdir('src')
subdir('po') subdir('po')
subdir('resources')
subdir('src')
gnome.post_install( if get_option('buildtype') == 'debug'
glib_compile_schemas: true, rust_args = []
gtk_update_icon_cache: true, rust_target = 'debug'
update_desktop_database: true, else
rust_args = ['--release']
rust_target = 'release'
endif
cargo = find_program('cargo')
cargo_env = environment()
cargo_env.set('MESON_BUILD_ROOT', meson.project_build_root())
cargo_env.append(
'RUSTFLAGS',
'-L ' + fs.parent(audrey_c.full_path()),
separator: ' ',
) )
custom_target(
'cargo-build',
build_by_default: true,
build_always_stale: true,
depends: [audrey_c, blueprints],
input: config_rs,
console: true,
# means nothing (always stale and uncopied), we can't cp since env: drops the /bin/sh wrapper
output: meson.project_name(),
command: [
cargo,
'build',
rust_args,
'--manifest-path',
meson.project_source_root() / 'Cargo.toml',
'--target-dir',
meson.project_build_root() / 'cargo-target',
],
env: cargo_env,
)
run_target('clippy', command: [cargo, 'clippy'], env: cargo_env)
run_target('cargo-doc', command: [cargo, 'doc'], env: cargo_env)

View file

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<gresources>
<gresource prefix="/eu/callcc/audrey">
<file>style.css</file>
<file preprocess="xml-stripblanks">gtk/help-overlay.ui</file>
<file preprocess="xml-stripblanks">play_queue.ui</file>
<file preprocess="xml-stripblanks">play_queue_song.ui</file>
<file preprocess="xml-stripblanks">playbar.ui</file>
<file preprocess="xml-stripblanks">setup.ui</file>
<file preprocess="xml-stripblanks">window.ui</file>
</gresource>
</gresources>

31
resources/meson.build Normal file
View file

@ -0,0 +1,31 @@
blueprints = custom_target(
'blueprints',
input: files(
'play_queue.blp',
'play_queue_song.blp',
'playbar.blp',
'setup.blp',
'window.blp',
),
output: [
'play_queue.ui',
'play_queue_song.ui',
'playbar.ui',
'setup.ui',
'window.ui',
],
command: [
find_program('blueprint-compiler'),
'batch-compile',
'@OUTDIR@',
'@CURRENT_SOURCE_DIR@',
'@INPUT@',
],
)
removeme = gnome.compile_resources(
'audrey-resources',
'audrey.gresource.xml',
c_name: 'audrey',
dependencies: blueprints,
)

46
resources/play_queue.blp Normal file
View file

@ -0,0 +1,46 @@
using Gtk 4.0;
using Adw 1;
template $AudreyUiPlayQueue: Adw.Bin {
name: "play-queue";
child: Stack {
visible-child-name: bind $visible_child_name (template.playbin as <$AudreyPlaybin>.play-queue-length) as <string>;
StackPage {
name: "empty";
child: Adw.StatusPage {
icon-name: "list-remove-all";
title: "No songs queued";
};
}
StackPage {
name: "not-empty";
child: ScrolledWindow {
hexpand: true;
vscrollbar-policy: always;
hscrollbar-policy: never;
ListView view {
show-separators: true;
single-click-activate: true;
activate => $on_row_activated ();
model: NoSelection {
model: bind template.playbin as <$AudreyPlaybin>.play_queue;
};
factory: SignalListItemFactory {
setup => $on_song_list_setup ();
bind => $on_song_list_bind ();
unbind => $on_song_list_unbind ();
};
}
};
}
};
}

View file

@ -0,0 +1,132 @@
using Gtk 4.0;
template $AudreyUiPlayQueueSong: Box {
height-request: 48;
spacing: 12;
margin-start: 6;
margin-end: 6;
Box {
width-request: 36;
focusable: false;
homogeneous: true;
Image {
visible: bind template.current;
focusable: false;
halign: end;
icon-size: normal;
icon-name: "media-playback-start";
}
Label {
visible: bind template.current inverted;
focusable: false;
halign: end;
justify: right;
styles [ "dim-label", "numeric" ]
label: bind template.displayed_position;
}
}
Image {
visible: bind template.show_cover;
margin-top: 1;
margin-bottom: 1;
pixel-size: 50;
paintable: bind template.song as <$AudreyPlaybinSong>.thumbnail;
}
Box title_box {
styles [ "header"]
focusable: false;
hexpand: true;
valign: center;
Box {
styles [ "title" ]
orientation: vertical;
Label {
styles [ "title" ]
focusable: false;
xalign: 0;
halign: start;
hexpand: true;
ellipsize: end;
max-width-chars: 90;
justify: fill;
label: bind template.song as <$AudreyPlaybinSong>.title;
}
Label {
styles [ "subtitle" ]
focusable: false;
xalign: 0;
halign: start;
hexpand: true;
ellipsize: end;
max-width-chars: 90;
justify: fill;
label: bind template.song as <$AudreyPlaybinSong>.artist;
}
}
}
Label {
focusable: false;
halign: end;
hexpand: true;
single-line-mode: true;
styles [ "numeric", "dim-label" ]
label: bind $format_duration (template.song as <$AudreyPlaybinSong>.duration) as <string>;
}
Button {
focusable: true;
// TODO icon-name: bind $star_button_icon_name (template.song as <$AudreyPlaybinSong>.starred) as <string>;
icon-name: bind $star_button_icon_name () as <string>;
styles [ "flat" ]
valign: center;
}
MenuButton {
//visible: false;
focusable: true;
icon-name: "view-more";
styles [ "flat" ]
valign: center;
popover: PopoverMenu {
menu-model: song-menu;
};
}
DragSource {
actions: move;
propagation-phase: capture;
prepare => $on_drag_prepare ();
drag-begin => $on_drag_begin ();
}
DropTarget {
actions: move;
formats: "AudreyUiPlayQueueSong";
preload: true;
drop => $on_drop ();
}
}
menu song-menu {
item ("View song", "song.view")
item ("View artist", "song.view-artist")
item ("View album", "song.view-album")
item ("Share", "song.share")
item ("Remove", "song.remove")
}

172
resources/playbar.blp Normal file
View file

@ -0,0 +1,172 @@
using Gtk 4.0;
using Adw 1;
template $AudreyUiPlaybar: Adw.Bin {
child: CenterBox {
hexpand: true;
styles [
"toolbar",
]
[start]
Box {
AspectFrame {
visible: false; // FIXME annoying annoying annoying annoying
//visible: bind template.show_cover_art;
vexpand: true;
ratio: 1.0;
obey-child: false;
child: Picture {
content-fit: scale_down;
paintable: bind template.playing_cover_art;
};
}
Box {
margin-start: 6;
orientation: vertical;
valign: center;
Label {
styles [ "heading" ]
xalign: 0;
halign: start;
label: bind $song_title (template.song) as <string>;
ellipsize: end;
}
Label {
styles [ "caption" ]
xalign: 0;
label: bind $song_artist (template.song) as <string>;
ellipsize: end;
}
Label {
styles [ "caption" ]
xalign: 0;
label: bind $song_album (template.song) as <string>;
ellipsize: end;
}
}
}
[center]
Box {
orientation: vertical;
valign: center;
CenterBox {
[start]
Label play_position_label {
styles [
"caption",
"numeric",
]
label: bind $format_timestamp (template.playbin as <$AudreyPlaybin>.position) as <string>;
}
[center]
Scale play_position {
name: "seek-scale";
orientation: horizontal;
width-request: 400;
sensitive: bind $playbin_active (template.playbin as <$AudreyPlaybin>.state as <$AudreyPlaybinState>) as <bool>;
adjustment: Adjustment {
lower: 0;
value: bind template.playbin as <$AudreyPlaybin>.position;
upper: bind template.playbin as <$AudreyPlaybin>.duration;
};
change-value => $on_play_position_seek (template);
}
[end]
Label play_duration {
styles [
"caption",
"numeric",
]
label: bind $format_timestamp (template.playbin as <$AudreyPlaybin>.duration) as <string>;
}
}
Box {
halign: center;
orientation: horizontal;
Button {
icon-name: "media-skip-backward";
valign: center;
sensitive: bind $playbin_active (template.playbin as <$AudreyPlaybin>.state as <$AudreyPlaybinState>) as <bool>;
clicked => $on_skip_backward_clicked (template);
}
Button {
icon-name: "media-seek-backward";
valign: center;
sensitive: bind $playbin_active (template.playbin as <$AudreyPlaybin>.state as <$AudreyPlaybinState>) as <bool>;
clicked => $seek_backward (template);
}
Button {
icon-name: bind $play_pause_icon_name (template.playbin as <$AudreyPlaybin>.state as <$AudreyPlaybinState>) as <string>;
valign: center;
sensitive: bind $can_press_play (template.playbin as <$AudreyPlaybin>.state as <$AudreyPlaybinState>, template.playbin as <$AudreyPlaybin>.play-queue-length) as <bool>;
clicked => $on_play_pause_clicked (template);
}
Button {
icon-name: "media-seek-forward";
valign: center;
sensitive: bind $playbin_active (template.playbin as <$AudreyPlaybin>.state as <$AudreyPlaybinState>) as <bool>;
clicked => $seek_forward (template);
}
Button {
icon-name: "media-skip-forward";
valign: center;
sensitive: bind $playbin_active (template.playbin as <$AudreyPlaybin>.state as <$AudreyPlaybinState>) as <bool>;
clicked => $on_skip_forward_clicked (template);
}
}
}
[end]
Box {
Button {
icon-name: "non-starred";
valign: center;
}
Button {
icon-name: bind $mute_button_icon_name (template.playbin as <$AudreyPlaybin>.mute) as <string>;
valign: center;
clicked => $on_mute_toggle (template);
}
Scale {
name: "volume-scale";
orientation: horizontal;
width-request: 130;
adjustment: Adjustment {
lower: 0;
value: bind template.volume bidirectional;
upper: 100;
};
}
}
};
}

View file

@ -1,7 +1,7 @@
using Gtk 4.0; using Gtk 4.0;
using Adw 1; using Adw 1;
template $UiSetup: Adw.PreferencesDialog { template $AudreyUiSetup: Adw.PreferencesDialog {
title: _("Setup"); title: _("Setup");
Adw.ToolbarView { Adw.ToolbarView {

View file

@ -1,3 +1,11 @@
/*
#play-queue-page {
background-image: linear-gradient(
var(--window-bg-color),
var(--accent-bg-color));
}
*/
#seek-scale slider { #seek-scale slider {
margin: 0px; margin: 0px;
opacity: 0%; opacity: 0%;
@ -11,6 +19,6 @@
min-height: 15px; min-height: 15px;
} }
.playing .title-label { #play-queue .playing label.title {
font-weight: bold; font-weight: bold;
} }

136
resources/window.blp Normal file
View file

@ -0,0 +1,136 @@
using Gtk 4.0;
using Adw 1;
template $AudreyUiWindow: Adw.ApplicationWindow {
title: _("audrey");
default-width: 800;
default-height: 600;
Adw.ToolbarView {
top-bar-style: raised;
bottom-bar-style: raised;
[top]
Adw.HeaderBar {
[start]
Button {
icon-name: "media-playlist-shuffle";
sensitive: bind template.can_click_shuffle_all;
clicked => $shuffle_all ();
}
title-widget: Adw.ViewSwitcher {
stack: stack;
policy: wide;
};
[end]
Button {
icon-name: "applications-system";
clicked => $show_setup_dialog ();
}
}
content: Adw.ViewStack stack {
vexpand: true;
Adw.ViewStackPage {
icon-name: "media-optical-cd";
title: _("Albums");
child: Adw.NavigationView {
Adw.NavigationPage {
title: _("Albums");
vexpand: true;
child: Adw.ToolbarView {
[top]
CenterBox {
styles [ "toolbar" ]
[start]
DropDown {
selected: 0;
model: StringList {
strings [
_("Random"),
_("Recently added"),
_("Most played"),
_("Recently played"),
_("Starred"),
_("Name"),
_("Artist"),
]
};
}
[center]
SearchEntry {
placeholder-text: _("Search...");
}
[end]
Box {
Button {
icon-name: "view-refresh";
}
}
}
ScrolledWindow {
vexpand: true;
GridView {}
}
};
}
};
}
Adw.ViewStackPage {
icon-name: "media-playback-start";
title: _("Play queue");
name: "play-queue";
child: Box {
name: "play-queue-page";
homogeneous: true;
Adw.Clamp {
halign: center;
margin-top: 24;
margin-bottom: 24;
margin-start: 24;
margin-end: 24;
Picture {
valign: center;
styles [ "frame" ]
halign: center;
paintable: bind template.playing_cover_art;
}
}
$AudreyUiPlayQueue play_queue {
hexpand: true;
halign: fill;
margin-top: 48;
margin-bottom: 48;
margin-start: 24;
margin-end: 24;
styles [ "frame" ]
playbin: bind template.playbin;
}
};
}
};
[bottom]
$AudreyUiPlaybar playbar {
song: bind template.song;
playbin: bind template.playbin;
playing_cover_art: bind template.playing_cover_art;
show_cover_art: bind $show_playbar_cover_art (stack.visible-child-name) as <bool>;
}
}
}

55
src/application.rs Normal file
View file

@ -0,0 +1,55 @@
mod imp {
use crate::ui;
use adw::prelude::*;
use adw::subclass::prelude::*;
use gtk::glib;
#[derive(Default)]
pub struct Application;
#[glib::object_subclass]
impl ObjectSubclass for Application {
const NAME: &'static str = "AudreyApplication";
type Type = super::Application;
type ParentType = adw::Application;
}
impl ObjectImpl for Application {}
impl ApplicationImpl for Application {
fn activate(&self) {
self.parent_activate();
match self.obj().active_window() {
None => ui::Window::new(self.obj().as_ref()).present(),
Some(win) => win.present(),
}
}
}
impl GtkApplicationImpl for Application {}
impl AdwApplicationImpl for Application {}
}
use gtk::{gio, glib};
glib::wrapper! {
pub struct Application(ObjectSubclass<imp::Application>)
@extends adw::Application, gtk::Application, gio::Application,
@implements gio::ActionGroup, gio::ActionMap;
}
impl Default for Application {
fn default() -> Self {
glib::Object::builder::<Application>()
.property("application-id", "eu.callcc.audrey")
.property("flags", gio::ApplicationFlags::default())
.build()
}
}
impl Application {
pub fn new() -> Self {
Self::default()
}
}

View file

@ -41,4 +41,8 @@ public class Audrey.Application : Adw.Application {
private void on_preferences_action () { private void on_preferences_action () {
message ("app.preferences action activated"); message ("app.preferences action activated");
} }
~Application () {
debug ("destroying application");
}
} }

View file

@ -1,11 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<gresources>
<gresource prefix="/eu/callcc/audrey">
<file>style.css</file>
<file preprocess="xml-stripblanks">gtk/help-overlay.ui</file>
<file preprocess="xml-stripblanks">ui/play_queue.ui</file>
<file preprocess="xml-stripblanks">ui/play_queue_song.ui</file>
<file preprocess="xml-stripblanks">ui/setup.ui</file>
<file preprocess="xml-stripblanks">ui/window.ui</file>
</gresource>
</gresources>

41
src/main.rs Normal file
View file

@ -0,0 +1,41 @@
mod application;
pub use application::Application;
mod meson_config;
pub mod ui;
pub mod mpris;
pub use mpris::Mpris;
pub mod playbin;
pub use playbin::Playbin;
pub mod subsonic;
use gettextrs::{bind_textdomain_codeset, bindtextdomain, setlocale, textdomain, LocaleCategory};
use gtk::prelude::*;
use gtk::{gio, glib};
pub mod mpv;
#[link(name = "audrey")]
#[link(name = "gcrypt")]
#[link(name = "json-glib-1.0")]
#[link(name = "secret-1")]
#[link(name = "soup-3.0")]
extern "C" {}
fn main() -> glib::ExitCode {
gio::resources_register_include!("audrey.gresource").expect("could not register resources");
ui::Playbar::ensure_type();
gtk::disable_setlocale();
bindtextdomain("audrey", meson_config::LOCALEDIR).expect("failed to bind text domain");
bind_textdomain_codeset("audrey", "UTF-8").expect("failed to bind textdomaincodeset");
textdomain("audrey").expect("unable to switch to text domain");
setlocale(LocaleCategory::LcAll, "");
setlocale(LocaleCategory::LcNumeric, "C.UTF-8");
let app = Application::new();
app.run()
}

View file

@ -1,17 +1,16 @@
audrey_sources = [ audrey_sources = [
'application.vala', 'application.vala',
'globalconf.vala', 'globalconf.vala',
'main.vala',
'mpris.vala', 'mpris.vala',
'playbin.vala', 'playbin.vala',
'subsonic.vala', 'subsonic.vala',
'ui/play_queue.vala', 'ui/play_queue.vala',
'ui/playbar.vapi',
'ui/setup.vala', 'ui/setup.vala',
'ui/window.vala', 'ui/window.vala',
] ]
audrey_deps = [ audrey_deps = [
config_dep,
dependency('gtk4', version: '>= 4.16'), dependency('gtk4', version: '>= 4.16'),
dependency('json-glib-1.0', version: '>= 1.10'), dependency('json-glib-1.0', version: '>= 1.10'),
dependency('libadwaita-1', version: '>= 1.6'), dependency('libadwaita-1', version: '>= 1.6'),
@ -21,42 +20,26 @@ audrey_deps = [
dependency('mpv', version: '>= 2.3'), dependency('mpv', version: '>= 2.3'),
] ]
subdir('ui') audrey_sources += removeme
audrey_sources += gnome.compile_resources( config_rs = configure_file(
'audrey-resources', output: 'meson_config.rs',
'audrey.gresource.xml', input: 'meson_config.rs.in',
c_name: 'audrey', configuration: config_h,
dependencies: blueprints,
# this is a huge hack, as by default only src/ui to the 'blueprints' dep
# and ../src is passed, which means the ui/xyz.ui prefix will not be found
# anywhere, as src/ui already has the ui in the path.
# the reason we have to put blp in its own ui folder, is because:
# - blueprint-compiler batch-compile outputs in the same directory structure,
# i.e. if you pass ui/x.blp outdir foo, it will create foo/ui/x.ui,
# - meson doesn't support output:[] with dir/ prefix
# .. which means, all the files are always dirty; meson will try find 'src/x.ui'
# as an output for dirty tracking, but blp is outputting src/ui/x.ui
# because of the directory structure.
# using manual blueprint-compiler invocations to put the output in the
# 'correct' tracked place doesn't work either,
# since then it will be in this dir and not in ui/ and so all the ui/ prefixes
# in gresources won't find it, and we can't put it in a ui/ output from the
# current dir due to the above and meson not letting you.
#so since we use ui/ prefix, we need a meson.build in ui/ and then this hack
# to ensure compile sees the ui/ prefix starting from this src/ dir still,
# despite the dependency normally doing this for you
# note that the 'documented' way to use blp is with outdir:[.] which is
# fundamentally broken as then nothing is tracked except the dirs existence, which
# is racy as the dependency is satistfied before anything is compiled
extra_args: ['--sourcedir', 'src'],
) )
executable( audrey_c = static_library(
'audrey', 'audrey',
audrey_sources, audrey_sources,
dependencies: audrey_deps, dependencies: audrey_deps,
include_directories: config_inc, include_directories: config_inc,
install: true, install: true,
vala_args: ['--vapidir', meson.current_source_dir() / 'vapi'], vala_args: [
'--vapidir',
meson.current_source_dir() / 'vapi',
'--gresources',
meson.project_source_root() / 'resources/audrey.gresource.xml',
],
vala_header: 'audrey.h',
vala_gir: 'audrey-0.gir',
) )

1
src/meson_config.rs Normal file
View file

@ -0,0 +1 @@
include!(concat!(env!("MESON_BUILD_ROOT"), "/src/meson_config.rs"));

1
src/meson_config.rs.in Normal file
View file

@ -0,0 +1 @@
pub static LOCALEDIR: &str = @LOCALEDIR@;

30
src/mpris.rs Normal file
View file

@ -0,0 +1,30 @@
mod player;
pub use player::Player;
mod ffi {
use gtk::glib;
#[repr(C)]
pub struct AudreyMpris {
parent_instance: glib::gobject_ffi::GObject,
}
#[repr(C)]
pub struct AudreyMprisClass {
parent_class: glib::gobject_ffi::GObjectClass,
}
extern "C" {
pub fn audrey_mpris_get_type() -> glib::ffi::GType;
}
}
use gtk::glib;
glib::wrapper! {
pub struct Mpris(Object<ffi::AudreyMpris, ffi::AudreyMprisClass>);
match fn {
type_ => || ffi::audrey_mpris_get_type(),
}
}

View file

@ -1,5 +1,5 @@
[DBus (name = "org.mpris.MediaPlayer2")] [DBus (name = "org.mpris.MediaPlayer2")]
class Mpris : Object { class Audrey.Mpris : Object {
internal signal void on_raise (); internal signal void on_raise ();
internal signal void on_quit (); internal signal void on_quit ();
@ -20,10 +20,19 @@ class Mpris : Object {
public string desktop_entry { owned get { return "eu.callcc.audrey"; } } public string desktop_entry { owned get { return "eu.callcc.audrey"; } }
public string[] supported_uri_schemes { owned get { return {}; } } public string[] supported_uri_schemes { owned get { return {}; } }
public string[] supported_mime_types { owned get { return {}; } } public string[] supported_mime_types { owned get { return {}; } }
internal Mpris (Ui.Window window) {
this.on_raise.connect (() => window.present ());
this.on_quit.connect (() => window.close ());
}
~Mpris () {
debug ("destroying mpris");
}
} }
[DBus (name = "org.mpris.MediaPlayer2.Player")] [DBus (name = "org.mpris.MediaPlayer2.Player")]
class MprisPlayer : Object { class Audrey.MprisPlayer : Object {
internal signal void on_next (); internal signal void on_next ();
internal signal void on_previous (); internal signal void on_previous ();
internal signal void on_pause (); internal signal void on_pause ();
@ -31,7 +40,7 @@ class MprisPlayer : Object {
internal signal void on_stop (); internal signal void on_stop ();
internal signal void on_play (); internal signal void on_play ();
internal signal void on_seek (int64 offset); internal signal void on_seek (int64 offset);
internal signal void on_set_position (string track_id, int64 position); internal signal void on_set_position (ObjectPath track_id, int64 position);
public void next () throws Error { this.on_next (); } public void next () throws Error { this.on_next (); }
public void previous () throws Error { this.on_previous (); } public void previous () throws Error { this.on_previous (); }
@ -40,26 +49,206 @@ class MprisPlayer : Object {
public void stop () throws Error { this.on_stop (); } public void stop () throws Error { this.on_stop (); }
public void play () throws Error { this.on_play (); } public void play () throws Error { this.on_play (); }
public void seek (int64 offset) throws Error { this.on_seek (offset); } public void seek (int64 offset) throws Error { this.on_seek (offset); }
public void set_position (string track_id, int64 position) throws Error { this.on_set_position (track_id, position); } public void set_position (ObjectPath track_id, int64 position) throws Error { this.on_set_position (track_id, position); }
public void open_uri (string uri) throws Error { assert (false); } public void open_uri (string uri) throws Error { assert (false); }
public signal void seeked (int64 position); public signal void seeked (int64 position);
public string playback_status { owned get; internal set; default = "Stopped"; } public string playback_status { owned get; internal set; default = "Stopped"; }
public string loop_status { owned get; set; default = "None"; } public string loop_status { owned get; /*set;*/ default = "None"; }
public double rate { get; set; default = 1.0; } public double rate { get; /*set*/ default = 1.0; }
public bool shuffle { get; set; default = false; } public bool shuffle { get; /*set*/ default = false; }
public HashTable<string, Variant> metadata_map { owned get; default = new HashTable<string,Variant>(null, null); } public HashTable<string, Variant> metadata { owned get; private set; default = new HashTable<string, Variant> (null, null); }
public double volume { get; set; default = 1.0; } public double volume { get; set; default = 1.0; }
[CCode (notify = false)] [CCode (notify = false)]
public int64 position { get; default = 0; } public int64 position { get; default = 0; }
public double minimum_rate { get { return 1.0; } } public double minimum_rate { get { return 1.0; } }
public double maximum_rate { get { return 1.0; } } public double maximum_rate { get { return 1.0; } }
public bool can_go_next { get; default = false; } public bool can_go_next { get; private set; default = false; }
public bool can_go_previous { get; default = false; } public bool can_go_previous { get; private set; default = false; }
public bool can_play { get; default = false; } public bool can_play { get; private set; default = false; }
public bool can_pause { get; default = false; } public bool can_pause { get; private set; default = false; }
public bool can_seek { get; default = false; } public bool can_seek { get; private set; default = false; }
[CCode (notify = false)] [CCode (notify = false)]
public bool can_control { get { return false; } } public bool can_control { get { return true; } }
internal Subsonic.Client api { get; set; }
internal MprisPlayer (DBusConnection conn, Playbin playbin) {
playbin.bind_property (
"state",
this,
"playback_status",
BindingFlags.DEFAULT,
(binding, from, ref to) => {
switch (from.get_enum ()) {
case PlaybinState.STOPPED:
to.set_string ("Stopped");
this.can_go_next = false;
this.can_go_previous = false;
this.can_play = false;
this.can_pause = false;
this.can_seek = false;
return true;
case PlaybinState.PAUSED:
to.set_string ("Paused");
this.can_go_next = true;
this.can_go_previous = true;
this.can_play = true;
this.can_pause = true;
this.can_seek = true;
return true;
case PlaybinState.PLAYING:
to.set_string ("Playing");
this.can_go_next = true;
this.can_go_previous = true;
this.can_play = true;
this.can_pause = true;
this.can_seek = true;
return true;
}
return false;
});
playbin.bind_property (
"volume",
this,
"volume",
BindingFlags.BIDIRECTIONAL,
(binding, from, ref to) => {
to.set_double (from.get_int () / 100.0);
return true;
},
(binding, from, ref to) => {
to.set_int ((int) (from.get_double () * 100.0));
return true;
});
playbin.new_track.connect ((playbin) => {
PlaybinSong song = (PlaybinSong) playbin.play_queue.get_item (playbin.play_queue_position);
var metadata = new HashTable<string, Variant> (null, null);
metadata["mpris:trackid"] = new ObjectPath (@"/eu/callcc/audrey/track/$(song.id)");
metadata["mpris:length"] = (int64) song.duration * 1000000;
if (this.api != null) metadata["mpris:artUrl"] = this.api.cover_art_uri (song.id);
metadata["xesam:album"] = song.album;
metadata["xesam:artist"] = new string[] {song.artist};
if (song.genre != null) metadata["xesam:genre"] = song.genre;
metadata["xesam:title"] = song.title;
metadata["xesam:trackNumber"] = song.track;
metadata["xesam:useCount"] = song.play_count;
// TODO metadata["xesam:userRating"] = song.starred != null ? 1.0 : 0.0;
this.metadata = metadata;
});
playbin.stopped.connect (() => {
this.metadata = new HashTable<string, Variant> (null, null);
});
this.on_next.connect (() => playbin.go_to_next_track ());
this.on_previous.connect (() => playbin.go_to_prev_track ());
this.on_play.connect (() => playbin.play ());
this.on_pause.connect (() => playbin.pause ());
this.on_play_pause.connect (() => {
if (playbin.state == PlaybinState.PAUSED) playbin.play ();
else if (playbin.state == PlaybinState.PLAYING) playbin.pause ();
});
this.on_stop.connect (() => {
playbin.stop ();
});
// TODO: seeking from mpris
// TODO: trigger the seeked signal when applicable
this.notify.connect ((p) => {
var builder = new VariantBuilder (VariantType.ARRAY);
var invalid_builder = new VariantBuilder (new VariantType ("as"));
string dbus_name;
Variant dbus_value;
switch (p.name) {
case "playback-status":
dbus_name = "PlaybackStatus";
dbus_value = this.playback_status;
break;
case "loop-status":
dbus_name = "LoopStatus";
dbus_value = this.loop_status;
break;
case "rate":
dbus_name = "Rate";
dbus_value = this.rate;
break;
case "shuffle":
dbus_name = "Shuffle";
dbus_value = this.shuffle;
break;
case "metadata":
dbus_name = "Metadata";
dbus_value = this.metadata;
break;
case "volume":
dbus_name = "Volume";
dbus_value = this.volume;
break;
case "minimum-rate":
dbus_name = "MinimumRate";
dbus_value = this.minimum_rate;
break;
case "maximum-rate":
dbus_name = "MaximumRate";
dbus_value = this.maximum_rate;
break;
case "can-go-next":
dbus_name = "CanGoNext";
dbus_value = this.can_go_next;
break;
case "can-go-previous":
dbus_name = "CanGoPrevious";
dbus_value = this.can_go_previous;
break;
case "can-play":
dbus_name = "CanPlay";
dbus_value = this.can_play;
break;
case "can-pause":
dbus_name = "CanPause";
dbus_value = this.can_pause;
break;
case "can-seek":
dbus_name = "CanSeek";
dbus_value = this.can_seek;
break;
case "api":
// internal, ignored
return;
default:
warning (@"unknown mpris player property $(p.name)");
return;
}
builder.add ("{sv}", dbus_name, dbus_value);
try {
conn.emit_signal (
null,
"/org/mpris/MediaPlayer2",
"org.freedesktop.DBus.Properties",
"PropertiesChanged",
new Variant (
"(sa{sv}as)",
"org.mpris.MediaPlayer2.Player",
builder,
invalid_builder));
} catch (Error e) {
error (@"could not notify of mpris property changes: $(e.message)");
}
});
}
~MprisPlayer () {
debug ("destroying mpris player");
}
} }

27
src/mpris/player.rs Normal file
View file

@ -0,0 +1,27 @@
mod ffi {
use gtk::glib;
#[repr(C)]
pub struct AudreyMprisPlayer {
parent_instance: glib::gobject_ffi::GObject,
}
#[repr(C)]
pub struct AudreyMprisClassPlayer {
parent_class: glib::gobject_ffi::GObjectClass,
}
extern "C" {
pub fn audrey_mpris_player_get_type() -> glib::ffi::GType;
}
}
use gtk::glib;
glib::wrapper! {
pub struct Player(Object<ffi::AudreyMprisPlayer, ffi::AudreyMprisClassPlayer>);
match fn {
type_ => || ffi::audrey_mpris_player_get_type(),
}
}

199
src/mpv.rs Normal file
View file

@ -0,0 +1,199 @@
mod ffi;
#[link(name = "mpv")]
extern "C" {}
pub struct Error(std::ffi::c_int);
impl Error {
fn from_return_code(rc: std::ffi::c_int) -> Result<(), Self> {
if rc == 0 {
Ok(())
} else {
Err(Self(rc))
}
}
}
impl std::fmt::Debug for Error {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> Result<(), std::fmt::Error> {
f.debug_tuple("Error")
.field(unsafe {
&std::ffi::CStr::from_ptr(ffi::mpv_error_string(self.0 as std::ffi::c_int))
})
.finish()
}
}
use std::borrow::Cow;
use std::ptr::NonNull;
pub struct Handle(NonNull<ffi::mpv_handle>);
impl Handle {
pub fn create() -> Self {
Self(NonNull::new(unsafe { ffi::mpv_create() }).expect("could not create mpv handle"))
}
pub fn initialize(&self) -> Result<(), Error> {
Error::from_return_code(unsafe { ffi::mpv_initialize(self.0.as_ptr()) })
}
pub fn wait_event(&mut self, timeout: f64) -> Option<Event<'_>> {
let event = unsafe { &*ffi::mpv_wait_event(self.0.as_ptr(), timeout) };
match event.event_id {
0 => None,
1 => Some(Event::Shutdown),
2 => todo!("Event::LogMessage"),
3 => Some(Event::GetPropertyReply(
event.reply_userdata,
Error::from_return_code(event.error).map(|()| {
let data = unsafe { &*(event.data as *mut ffi::mpv_event_property) };
EventProperty {
name: unsafe { std::ffi::CStr::from_ptr(data.name) }
.to_str()
.unwrap(),
value: unsafe { FormatData::new(data.format, data.data) },
}
}),
)),
4 => todo!("Event::SetPropertyReply"),
5 => todo!("Event::CommandReply"),
6 => todo!("Event::StartFile"),
7 => todo!("Event::EndFile"),
8 => todo!("Event::FileLoaded"),
16 => todo!("Event::ClientMessage"),
17 => todo!("Event::VideoReconfig"),
18 => todo!("Event::AudioReconfig"),
20 => todo!("Event::Seek"),
21 => todo!("Event::PlaybackRestart"),
22 => todo!("Event::PropertyChange"),
24 => todo!("Event::QueueOverflow"),
25 => todo!("Event::Hook"),
_ => Some(Event::Ignore(event.event_id)),
}
}
}
pub enum FormatData<'a> {
None,
String(Cow<'a, str>),
OsdString(&'a str),
Flag(bool),
Int64(i64),
Double(f64),
//Node(Node<'a>),
}
impl<'a> FormatData<'a> {
unsafe fn new(format: ffi::mpv_format, data: *mut std::ffi::c_void) -> Self {
match format {
0 => Self::None,
1 => {
let data = data as *mut *mut std::ffi::c_char;
Self::String(String::from_utf8_lossy(
std::ffi::CStr::from_ptr(*data).to_bytes(),
))
}
2 => {
let data = data as *mut *mut std::ffi::c_char;
Self::OsdString(
std::ffi::CStr::from_ptr(*data)
.to_str()
.expect("OSD string wasn't UTF-8"),
)
}
3 => {
let data = data as *mut std::ffi::c_int;
Self::Flag(match *data {
0 => false,
1 => true,
_ => panic!("invalid mpv flag value"),
})
}
4 => {
let data = data as *mut i64;
Self::Int64(*data)
}
5 => {
let data = data as *mut f64;
Self::Double(*data)
}
6 => todo!(),
7 => todo!(),
8 => todo!(),
9 => todo!(),
_ => panic!("invalid mpv format"),
}
}
}
pub struct EventProperty<'a> {
pub name: &'a str,
pub value: FormatData<'a>,
}
pub struct EventLogMessage<'a> {
pub prefix: &'a str,
pub level: &'a str,
pub text: &'a str,
pub log_level: i32,
}
pub enum EndFileReason {
Eof,
Stop,
Quit,
Error(Error),
Redirect,
}
pub struct EventStartFile {
pub playlist_entry_id: i64,
}
pub struct EventEndFile {
pub reason: EndFileReason,
pub playlist_entry_id: i64,
pub playlist_insert_id: i64,
pub playlist_insert_num_entries: i32,
}
pub struct EventClientMessage<'a> {
pub num_args: i32,
pub args: &'a [&'a str],
}
pub struct EventHook<'a> {
pub name: &'a str,
pub id: u64,
}
pub enum Event<'a> {
Shutdown,
LogMessage(EventLogMessage<'a>),
GetPropertyReply(u64, Result<EventProperty<'a>, Error>),
SetPropertyReply(u64, Result<(), Error>),
//CommandReply(u64, Result<Node<'a>, Error>),
StartFile(EventStartFile),
EndFile(EventEndFile),
FileLoaded,
ClientMessage(EventClientMessage<'a>),
VideoReconfig,
AudioReconfig,
Seek,
PlaybackRestart,
PropertyChange(u64, EventProperty<'a>),
QueueOverflow,
Hook(u64, EventHook<'a>),
// "Keep in mind that later ABI compatible releases might add new event
// types. These should be ignored by the API user."
Ignore(u32),
}
impl Drop for Handle {
fn drop(&mut self) {
// destroy? terminate_destroy??
todo!()
}
}

6
src/mpv/ffi.rs Normal file
View file

@ -0,0 +1,6 @@
#![allow(dead_code)]
#![allow(non_camel_case_types)]
#![allow(non_snake_case)]
#![allow(non_upper_case_globals)]
include!(concat!(env!("OUT_DIR"), "/mpv_ffi.rs"));

4
src/mpv/wrapper.h Normal file
View file

@ -0,0 +1,4 @@
#define MPV_ENABLE_DEPRECATED 0
#include <mpv/client.h>
const int BUILD_VERSION = MPV_CLIENT_API_VERSION;

77
src/playbin.rs Normal file
View file

@ -0,0 +1,77 @@
mod song;
pub use song::Song;
mod state;
pub use state::State;
mod ffi {
use gtk::glib;
#[repr(C)]
pub struct AudreyPlaybin {
parent_instance: glib::gobject_ffi::GObject,
}
#[repr(C)]
pub struct AudreyPlaybinClass {
parent_class: glib::gobject_ffi::GObjectClass,
}
extern "C" {
pub fn audrey_playbin_get_type() -> glib::ffi::GType;
pub fn audrey_playbin_get_state(self_: *mut AudreyPlaybin) -> super::state::ffi::State;
pub fn audrey_playbin_pause(self_: *mut AudreyPlaybin);
pub fn audrey_playbin_play(self_: *mut AudreyPlaybin);
pub fn audrey_playbin_get_volume(self_: *mut AudreyPlaybin) -> std::ffi::c_int;
pub fn audrey_playbin_set_volume(self_: *mut AudreyPlaybin, volume: std::ffi::c_int);
pub fn audrey_playbin_seek(self_: *mut AudreyPlaybin, position: f64);
}
}
use gtk::glib;
glib::wrapper! {
pub struct Playbin(Object<ffi::AudreyPlaybin, ffi::AudreyPlaybinClass>);
match fn {
type_ => || ffi::audrey_playbin_get_type(),
}
}
impl Playbin {
pub fn get_state(&self) -> State {
use glib::translate::ToGlibPtr;
unsafe { glib::translate::from_glib(ffi::audrey_playbin_get_state(self.to_glib_none().0)) }
}
pub fn pause(&self) {
use glib::translate::ToGlibPtr;
unsafe { ffi::audrey_playbin_pause(self.to_glib_none().0) }
}
pub fn play(&self) {
use glib::translate::ToGlibPtr;
unsafe { ffi::audrey_playbin_play(self.to_glib_none().0) }
}
pub fn get_volume(&self) -> i32 {
use glib::translate::ToGlibPtr;
unsafe { ffi::audrey_playbin_get_volume(self.to_glib_none().0) }
}
pub fn set_volume(&self, value: i32) {
use glib::translate::ToGlibPtr;
unsafe { ffi::audrey_playbin_set_volume(self.to_glib_none().0, value) }
}
pub fn seek(&self, position: f64) {
use glib::translate::ToGlibPtr;
unsafe { ffi::audrey_playbin_seek(self.to_glib_none().0, position) }
}
}

View file

@ -1,15 +1,75 @@
public enum PlaybinState { public enum Audrey.PlaybinState {
STOPPED, STOPPED,
PAUSED, PAUSED,
PLAYING, PLAYING,
} }
public class Playbin : GLib.Object { private struct Audrey.CommandCallback {
unowned SourceFunc callback;
int error;
}
public class Audrey.PlaybinSong : Object {
private Subsonic.Song inner;
public string id { get { return inner.id; } }
public string title { get { return inner.title; } }
public string artist { get { return inner.artist; } }
public string album { get { return inner.album; } }
public string? genre { get { return inner.genre; } }
public int64 duration { get { return inner.duration; } }
public int64 track { get { return inner.track; } }
public int64 play_count { get { return inner.play_count; } }
public Gdk.Paintable? thumbnail { get; private set; }
private Cancellable cancel_loading_thumbnail;
public PlaybinSong (Subsonic.Client api, Subsonic.Song song) {
this.api = api;
this.inner = song;
}
private Subsonic.Client api;
public void need_cover_art () {
/* TODO
if (this.cancel_loading_thumbnail != null) return;
if (this.thumbnail != null) return;
this.cancel_loading_thumbnail = new Cancellable ();
// TODO: dpi scaling maybe?? probably
api.cover_art.begin (this.id, 50, Priority.LOW, this.cancel_loading_thumbnail, (obj, res) => {
try {
var pixbuf = api.cover_art.end (res);
this.thumbnail = Gdk.Texture.for_pixbuf (pixbuf);
} catch (Error e) {
if (!(e is IOError.CANCELLED)) {
warning ("could not fetch cover art for song %s: %s", this.id, e.message);
}
}
this.cancel_loading_thumbnail = null;
});
*/
}
~PlaybinSong () {
if (this.cancel_loading_thumbnail != null) {
this.cancel_loading_thumbnail.cancel ();
}
}
}
public class Audrey.Playbin : GLib.Object {
private Mpv.Handle mpv = new Mpv.Handle (); private Mpv.Handle mpv = new Mpv.Handle ();
private int _volume = 100;
private bool _mute = false;
private ListStore _play_queue = new ListStore (typeof (PlaybinSong));
// try to prevent wait_event to be called twice
private bool is_handling_event = false;
public PlaybinState state { get; private set; default = PlaybinState.STOPPED; } public PlaybinState state { get; private set; default = PlaybinState.STOPPED; }
private int _volume = 100;
public int volume { public int volume {
get { return _volume; } get { return _volume; }
set { set {
@ -22,7 +82,6 @@ public class Playbin : GLib.Object {
} }
} }
public bool _mute = false;
public bool mute { public bool mute {
get { return _mute; } get { return _mute; }
set { set {
@ -35,50 +94,36 @@ public class Playbin : GLib.Object {
} }
} }
public uint play_queue_position { get; private set; } // invariant: negative iff stopped, otherwise < play queue length
public int play_queue_position { get; private set; default = -1; }
public signal void now_playing (Subsonic.Song now, Subsonic.Song? next); // signalled when a new track is current
public signal void new_track ();
// signalled when the last track is over
public signal void stopped (); public signal void stopped ();
// these are mostly synced with mpv
public double position { get; private set; default = 0.0; } public double position { get; private set; default = 0.0; }
public double duration { get; private set; default = 0.0; } public double duration { get; private set; default = 0.0; }
public Subsonic.Client api { get; set; default = null; } public weak Subsonic.Client api { get; set; default = null; }
public ListStore play_queue { get; private set; } public ListModel play_queue { get { return this._play_queue; } }
public uint play_queue_length { get; private set; default = 0; }
private void on_play_queue_items_changed (ListModel play_queue, uint position, uint removed, uint added) { private async Mpv.Error mpv_command_async (string[] args) {
for (uint i = 0; i < removed; i += 1) { CommandCallback cc = {};
assert (this.mpv.command ({
"playlist-remove",
position.to_string (),
}) >= 0);
}
for (uint i = 0; i < added; i += 1) { this.mpv.command_async ((uint64) &cc, args);
assert (this.mpv.command ({
"loadfile",
this.api.stream_uri (((Subsonic.Song) play_queue.get_item (position+i)).id),
"insert-at-play",
(position+i).to_string (),
}) >= 0);
}
if (this.play_queue_position == position && removed > 0) { cc.callback = this.mpv_command_async.callback;
if (this.play_queue_position < this.play_queue.get_n_items ()) { yield;
// edge case: new track plays, playlist-pos doesn't change, so now_playing never n gets triggered return cc.error;
this.now_playing (
(Subsonic.Song) this.play_queue.get_item (this.play_queue_position),
(Subsonic.Song?) this.play_queue.get_item (this.play_queue_position+1));
}
}
} }
public Playbin () { public Playbin () {
this.play_queue = new ListStore (typeof (Subsonic.Song));
this.play_queue.items_changed.connect (this.on_play_queue_items_changed);
assert (this.mpv.initialize () >= 0); assert (this.mpv.initialize () >= 0);
assert (this.mpv.set_property_string ("audio-client-name", "audrey") >= 0);
assert (this.mpv.set_property_string ("user-agent", Audrey.Const.user_agent) >= 0); assert (this.mpv.set_property_string ("user-agent", Audrey.Const.user_agent) >= 0);
assert (this.mpv.set_property_string ("video", "no") >= 0); assert (this.mpv.set_property_string ("video", "no") >= 0);
assert (this.mpv.set_property_string ("prefetch-playlist", "yes") >= 0); assert (this.mpv.set_property_string ("prefetch-playlist", "yes") >= 0);
@ -86,9 +131,16 @@ public class Playbin : GLib.Object {
assert (this.mpv.observe_property (0, "time-pos", Mpv.Format.DOUBLE) >= 0); assert (this.mpv.observe_property (0, "time-pos", Mpv.Format.DOUBLE) >= 0);
assert (this.mpv.observe_property (1, "duration", Mpv.Format.DOUBLE) >= 0); assert (this.mpv.observe_property (1, "duration", Mpv.Format.DOUBLE) >= 0);
assert (this.mpv.observe_property (2, "playlist-pos", Mpv.Format.INT64) >= 0); assert (this.mpv.observe_property (2, "playlist-pos", Mpv.Format.INT64) >= 0);
assert (this.mpv.observe_property (3, "pause", Mpv.Format.FLAG) >= 0);
this.mpv.wakeup_callback = () => { this.mpv.wakeup_callback = () => {
Idle.add (() => { Idle.add (() => {
if (this.is_handling_event) {
warning ("main thread mpv wakeup callback called twice");
return false;
}
this.is_handling_event = true;
while (true) { while (true) {
var event = this.mpv.wait_event (0.0); var event = this.mpv.wait_event (0.0);
if (event.event_id == Mpv.EventId.NONE) break; if (event.event_id == Mpv.EventId.NONE) break;
@ -109,25 +161,42 @@ public class Playbin : GLib.Object {
case 1: case 1:
assert (data.name == "duration"); assert (data.name == "duration");
if (data.format == Mpv.Format.NONE) { if (data.format == Mpv.Format.NONE) {
this.duration = 0.0; // this.duration = 0.0; i think this prevents the fallback below from working
} else { } else {
this.duration = data.parse_double (); this.duration = data.parse_double ();
} }
break; break;
case 2: case 2:
// here as a sanity check
// should always match our own play_queu_position/state
assert (data.name == "playlist-pos"); assert (data.name == "playlist-pos");
if (data.parse_int64 () < 0) { int64 playlist_pos = data.parse_int64 ();
debug ("playlist-pos is null, sending stopped event"); if (playlist_pos < 0) {
this.play_queue_position = this.play_queue.get_n_items (); if (this.state != PlaybinState.STOPPED) {
this.state = PlaybinState.STOPPED; error ("mpv has no current playlist entry, but we think it's index %d", this.play_queue_position);
this.stopped (); }
assert (this.play_queue_position < 0);
} else { } else {
this.play_queue_position = (uint) data.parse_int64 (); if (this.state == PlaybinState.STOPPED) {
debug (@"playlist-pos has been updated to $(this.play_queue_position)"); error ("mpv is at playlist entry %d, but we're stopped", (int) playlist_pos);
this.now_playing ( }
(Subsonic.Song) this.play_queue.get_item (this.play_queue_position), if (this.play_queue_position != (int) playlist_pos) {
(Subsonic.Song?) this.play_queue.get_item (this.play_queue_position+1)); error ("mpv is at playlist entry %d, but we think it's %d", (int) playlist_pos, this.play_queue_position);
}
}
break;
case 3:
// also here as a sanity check
// should always match our own state
assert (data.name == "pause");
bool pause = data.parse_flag ();
if (pause && this.state != PlaybinState.PAUSED) {
error (@"mpv is paused, but we are @(this.state)");
}
if (!pause && this.state == PlaybinState.PAUSED) {
error ("mpv is not paused, but we are paused");
} }
break; break;
@ -139,14 +208,40 @@ public class Playbin : GLib.Object {
case Mpv.EventId.START_FILE: case Mpv.EventId.START_FILE:
debug ("START_FILE received"); debug ("START_FILE received");
// estimate duration from api data
// while mpv doesn't know it
this.duration = ((PlaybinSong) this._play_queue.get_item (this.play_queue_position)).duration;
this.new_track ();
break; break;
case Mpv.EventId.END_FILE: case Mpv.EventId.END_FILE:
debug ("END_FILE received");
var data = event.parse_end_file (); var data = event.parse_end_file ();
debug (@"END_FILE received (reason: $(data.reason))");
if (data.error < 0) { if (data.error < 0) {
warning ("playback of track aborted: %s", data.error.to_string ()); warning ("playback of track aborted: %s", data.error.to_string ());
} }
if (data.reason == Mpv.EndFileReason.EOF) {
// assume this is a proper transition
this.play_queue_position += 1;
if (this.play_queue_position == this._play_queue.get_n_items ()) {
// reached the end (?)
this.state = PlaybinState.STOPPED;
this.play_queue_position = -1;
this.stopped ();
}
}
break;
case Mpv.EventId.COMMAND_REPLY:
unowned CommandCallback *cc = (CommandCallback *) event.reply_userdata;
cc.error = event.error;
cc.callback ();
break; break;
default: default:
@ -155,14 +250,19 @@ public class Playbin : GLib.Object {
} }
} }
this.is_handling_event = false;
return false; return false;
}); });
}; };
} }
public void seek (double position) { public void seek (double position) {
var rc = this.mpv.command ({"seek", position.to_string (), "absolute"});
if (rc < 0) {
warning (@"could not seek to $position: $rc");
} else {
this.position = position; this.position = position;
assert (this.mpv.command ({"seek", position.to_string (), "absolute"}) >= 0); }
} }
// manually changes which track in the play queue to play // manually changes which track in the play queue to play
@ -170,7 +270,9 @@ public class Playbin : GLib.Object {
requires (position < this.play_queue.get_n_items ()) requires (position < this.play_queue.get_n_items ())
{ {
assert (this.mpv.command ({"playlist-play-index", position.to_string ()}) >= 0); assert (this.mpv.command ({"playlist-play-index", position.to_string ()}) >= 0);
this.play_queue_position = (int) position;
this.state = PlaybinState.PLAYING; this.state = PlaybinState.PLAYING;
this.play (); // make sure mpv actually starts playing the track
} }
public void pause () { public void pause () {
@ -185,7 +287,12 @@ public class Playbin : GLib.Object {
} }
public void play () { public void play () {
assert (this.state != PlaybinState.STOPPED); if (this.state == PlaybinState.STOPPED) {
// allow only when playlist is not empty
// and start from the top
assert (this._play_queue.get_n_items () > 0);
this.select_track (0);
} else {
this.state = PlaybinState.PLAYING; this.state = PlaybinState.PLAYING;
debug ("setting state to playing"); debug ("setting state to playing");
var ret = this.mpv.set_property_flag("pause", false); var ret = this.mpv.set_property_flag("pause", false);
@ -193,16 +300,139 @@ public class Playbin : GLib.Object {
debug (@"failed to set state to playing ($(ret)): $(ret.to_string())"); debug (@"failed to set state to playing ($(ret)): $(ret.to_string())");
} }
} }
public void next_track () {
assert (this.state != PlaybinState.STOPPED);
this.state = PlaybinState.PLAYING;
assert (this.mpv.command ({"playlist-next-playlist"}) >= 0);
} }
public void prev_track () { public void go_to_next_track ()
assert (this.state != PlaybinState.STOPPED); requires (this.state != PlaybinState.STOPPED)
this.state = PlaybinState.PLAYING; {
if (this.play_queue_position+1 < this._play_queue.get_n_items ()) {
this.play_queue_position += 1;
assert (this.mpv.command ({"playlist-next-playlist"}) >= 0);
} else {
warning ("tried to skip forward at end of play queue, ignoring");
}
}
public void go_to_prev_track ()
requires (this.state != PlaybinState.STOPPED)
{
if (this.play_queue_position > 0) {
this.play_queue_position -= 1;
assert (this.mpv.command ({"playlist-prev-playlist"}) >= 0); assert (this.mpv.command ({"playlist-prev-playlist"}) >= 0);
} else {
warning ("tried to skip to prev track at start of play queue, ignoring");
}
}
public void remove_track (uint position)
requires (position < this._play_queue.get_n_items ())
{
assert (this.mpv.command({"playlist-remove", position.to_string ()}) >= 0);
this._play_queue.remove (position);
this.play_queue_length -= 1;
if (this.play_queue_position > position) this.play_queue_position -= 1;
if (this.play_queue_position == this._play_queue.get_n_items ()) {
// we just killed the last track
this.state = PlaybinState.STOPPED;
this.play_queue_position = -1;
this.stopped ();
}
}
public void clear () {
assert (this.mpv.command({"playlist-clear"}) >= 0);
if (this.state != PlaybinState.STOPPED) {
assert (this.mpv.command({"playlist-remove", "current"}) >= 0);
}
this.state = PlaybinState.STOPPED;
this._play_queue.remove_all ();
this.play_queue_length = 0;
this.play_queue_position = -1;
this.stopped ();
}
public void append_track (Subsonic.Song song) {
assert (this.mpv.command ({
"loadfile",
this.api.stream_uri (song.id),
"append",
}) >= 0);
this._play_queue.append (new PlaybinSong (this.api, song));
this.play_queue_length += 1;
}
public async void append_track_async (Subsonic.Song song) {
var err = yield this.mpv_command_async ({
"loadfile",
this.api.stream_uri (song.id),
"append",
});
assert (err >= 0);
this._play_queue.append (new PlaybinSong (this.api, song));
this.play_queue_length += 1;
}
public void move_track (uint from, uint to)
requires (from < this._play_queue.get_n_items ())
requires (to < this._play_queue.get_n_items ())
{
debug (@"moving track $from to $to");
if (from < to) {
// why offset to? because if the playlist is 01234,
// mpv takes "move 1 to 3" to mean 02134, not 02314
// that is, the target is a "gap", not a playlist entry
// from -> 0 1 2 3 4 5
// to -> 0 1 2 3 4 5 6
assert(this.mpv.command({
"playlist-move",
from.to_string (),
(to+1).to_string (),
}) >= 0);
// F0123T -> 0123TF
var additions = new Object[to-from+1];
for (uint i = from+1; i < to; i += 1) {
additions[i-from-1] = this._play_queue.get_item (i);
}
additions[to-from-1] = this._play_queue.get_item (to);
additions[to-from] = this._play_queue.get_item (from);
this._play_queue.splice(from, to-from+1, additions);
if (this.play_queue_position == (int) from) this.play_queue_position = (int) to;
else if (this.play_queue_position > (int) from && this.play_queue_position <= (int) to) this.play_queue_position -= 1;
} else if (from > to) {
assert(this.mpv.command({
"playlist-move",
from.to_string (),
to.to_string (),
}) >= 0);
// T0123F -> FT0123
var additions = new Object[from-to+1];
additions[0] = this._play_queue.get_item (from);
for (uint i = to; i < from; i += 1) {
additions[i-to+1] = this._play_queue.get_item (i);
}
this._play_queue.splice (to, from-to+1, additions);
if (this.play_queue_position == (int) from) this.play_queue_position = (int) to;
else if (this.play_queue_position >= (int) to && this.play_queue_position < (int) from) this.play_queue_position += 1;
}
}
public void stop () {
debug ("stopping playback");
// don't clear the playlist, just in case (less state changes to sync)
assert(this.mpv.command({"stop", "keep-playlist"}) >= 0);
this.state = PlaybinState.STOPPED;
this.play_queue_position = -1;
this.stopped ();
}
~Playbin () {
debug ("destroying playbin");
} }
} }

68
src/playbin/song.rs Normal file
View file

@ -0,0 +1,68 @@
mod ffi {
use gtk::glib;
#[repr(C)]
pub struct AudreyPlaybinSong {
parent_instance: glib::gobject_ffi::GObject,
}
#[repr(C)]
pub struct AudreyPlaybinSongClass {
parent_class: glib::gobject_ffi::GObjectClass,
}
extern "C" {
pub fn audrey_playbin_song_get_type() -> glib::ffi::GType;
pub fn audrey_playbin_song_get_title(
self_: *mut AudreyPlaybinSong,
) -> *const std::ffi::c_char;
pub fn audrey_playbin_song_get_artist(
self_: *mut AudreyPlaybinSong,
) -> *const std::ffi::c_char;
pub fn audrey_playbin_song_get_album(
self_: *mut AudreyPlaybinSong,
) -> *const std::ffi::c_char;
}
}
use gtk::glib;
glib::wrapper! {
pub struct Song(Object<ffi::AudreyPlaybinSong, ffi::AudreyPlaybinSongClass>);
match fn {
type_ => || ffi::audrey_playbin_song_get_type(),
}
}
impl Song {
pub fn get_title(&self) -> std::borrow::Cow<'_, str> {
use glib::translate::ToGlibPtr;
// TODO: memory management....
String::from_utf8_lossy(unsafe {
std::ffi::CStr::from_ptr(ffi::audrey_playbin_song_get_title(self.to_glib_none().0))
.to_bytes()
})
}
pub fn get_artist(&self) -> std::borrow::Cow<'_, str> {
use glib::translate::ToGlibPtr;
// TODO: memory management....
String::from_utf8_lossy(unsafe {
std::ffi::CStr::from_ptr(ffi::audrey_playbin_song_get_artist(self.to_glib_none().0))
.to_bytes()
})
}
pub fn get_album(&self) -> std::borrow::Cow<'_, str> {
use glib::translate::ToGlibPtr;
// TODO: memory management....
String::from_utf8_lossy(unsafe {
std::ffi::CStr::from_ptr(ffi::audrey_playbin_song_get_album(self.to_glib_none().0))
.to_bytes()
})
}
}

46
src/playbin/state.rs Normal file
View file

@ -0,0 +1,46 @@
pub mod ffi {
use gtk::glib;
pub type State = std::ffi::c_int;
extern "C" {
pub fn audrey_playbin_state_get_type() -> glib::ffi::GType;
}
}
use glib::prelude::*;
use gtk::glib;
#[derive(Copy, Clone, PartialEq, Eq)]
pub enum State {
Stopped,
Paused,
Playing,
}
impl glib::translate::FromGlib<ffi::State> for State {
unsafe fn from_glib(value: ffi::State) -> Self {
match value {
0 => Self::Stopped,
1 => Self::Paused,
2 => Self::Playing,
_ => unreachable!(),
}
}
}
unsafe impl<'a> glib::value::FromValue<'a> for State {
type Checker = glib::value::GenericValueTypeChecker<Self>;
unsafe fn from_value(value: &'a glib::Value) -> Self {
use glib::translate::ToGlibPtr;
glib::translate::from_glib(glib::gobject_ffi::g_value_get_enum(value.to_glib_none().0))
}
}
impl StaticType for State {
fn static_type() -> glib::Type {
unsafe { glib::translate::from_glib(ffi::audrey_playbin_state_get_type()) }
}
}

5
src/subsonic.rs Normal file
View file

@ -0,0 +1,5 @@
mod client;
pub use client::Client;
mod song;
pub use song::Song;

View file

@ -1,11 +1,11 @@
public errordomain Subsonic.Error { public errordomain Audrey.Subsonic.Error {
BAD_AUTHN, BAD_AUTHN,
ERROR, ERROR,
} }
public delegate void Subsonic.SongCallback (Song song); public delegate void Audrey.Subsonic.SongCallback (Song song);
public class Subsonic.Artist : Object { public class Audrey.Subsonic.Artist : Object {
public string index; public string index;
public string id; public string id;
public string name { get; private set; } public string name { get; private set; }
@ -38,7 +38,7 @@ public class Subsonic.Artist : Object {
} }
} }
public class Subsonic.Album : Object { public class Audrey.Subsonic.Album : Object {
public string id; public string id;
public string name; public string name;
@ -53,15 +53,18 @@ public class Subsonic.Album : Object {
} }
} }
public class Subsonic.Song : Object { public struct Audrey.Subsonic.Song {
public string id { get; private set; } public string id;
public string title { get; private set; } public string title;
public string album { get; private set; } public string album;
public string artist { get; private set; } public string artist;
public int64 track { get; private set; } public int64 track;
public int64 year { get; private set; } public int64 year;
public DateTime? starred { get; private set; } public DateTime? starred; // TODO
public int64 duration { get; private set; } public int64 duration;
public int64 play_count;
public string? genre;
public string cover_art;
public Song (Json.Reader reader) { public Song (Json.Reader reader) {
reader.read_member ("id"); reader.read_member ("id");
@ -91,53 +94,22 @@ public class Subsonic.Song : Object {
reader.read_member ("duration"); reader.read_member ("duration");
this.duration = reader.get_int_value (); this.duration = reader.get_int_value ();
reader.end_member (); reader.end_member ();
reader.read_member ("playCount");
this.play_count = reader.get_int_value ();
reader.end_member ();
reader.read_member ("genre");
this.genre = reader.get_string_value ();
reader.end_member ();
reader.read_member ("coverArt");
this.cover_art = reader.get_string_value ();
reader.end_member ();
} }
} }
public struct Subsonic.PlayQueue { public class Audrey.Subsonic.Client : Object {
public string current;
public int64 position;
public DateTime changed;
public string changed_by;
public ListStore songs;
internal PlayQueue.from_reader (Json.Reader reader) {
reader.read_member ("current");
this.current = reader.get_string_value ();
reader.end_member ();
reader.read_member ("position");
this.position = reader.get_int_value ();
reader.end_member ();
reader.read_member ("changed");
this.changed = new DateTime.from_iso8601 (reader.get_string_value (), null);
reader.end_member ();
reader.read_member ("changed_by");
this.changed_by = reader.get_string_value ();
reader.end_member ();
//print("%s %lli %s %s\n",this.current, this.position, this.changed, this.changed_by);
this.songs = new ListStore (typeof (Song));
reader.read_member ("song");
for (int i = 0; i < reader.count_elements (); i += 1) {
reader.read_element (i);
this.songs.append (new Song (reader));
reader.end_element ();
}
reader.end_member ();
assert (reader.get_error () == null);
}
}
public class Subsonic.Client : Object {
public ListStore artist_list;
public ListStore album_list;
public ListStore song_list;
private Soup.Session session; private Soup.Session session;
private string url; private string url;
private string parameters; private string parameters;
@ -148,10 +120,6 @@ public class Subsonic.Client : Object {
this.session = new Soup.Session (); this.session = new Soup.Session ();
this.session.user_agent = Audrey.Const.user_agent; this.session.user_agent = Audrey.Const.user_agent;
this.artist_list = new ListStore (typeof (Artist));
this.album_list = new ListStore (typeof (Album));
this.song_list = new ListStore (typeof (Song));
} }
private void unwrap_response (Json.Reader reader) throws GLib.Error { private void unwrap_response (Json.Reader reader) throws GLib.Error {
@ -222,8 +190,11 @@ public class Subsonic.Client : Object {
for (int i = 0; i < reader.count_elements (); i += 1) { for (int i = 0; i < reader.count_elements (); i += 1) {
reader.read_element (i); reader.read_element (i);
callback (new Song (reader)); callback (Song (reader));
reader.end_element (); reader.end_element ();
Idle.add (this.get_random_songs.callback);
yield;
} }
assert (reader.get_error () == null); assert (reader.get_error () == null);
@ -233,16 +204,33 @@ public class Subsonic.Client : Object {
return @"$(this.url)/rest/stream?id=$(Uri.escape_string(id))&$(this.parameters)"; return @"$(this.url)/rest/stream?id=$(Uri.escape_string(id))&$(this.parameters)";
} }
public string cover_art_uri (string id) { public string cover_art_uri (string id, int size = -1) {
if (size >= 0) {
return @"$(this.url)/rest/getCoverArt?id=$(Uri.escape_string(id))&$(this.parameters)"; return @"$(this.url)/rest/getCoverArt?id=$(Uri.escape_string(id))&$(this.parameters)";
} else {
return @"$(this.url)/rest/getCoverArt?size=$size&id=$(Uri.escape_string(id))&$(this.parameters)";
}
} }
public async Gdk.Pixbuf cover_art (string id, Cancellable cancellable) throws GLib.Error { public async Gdk.Pixbuf cover_art (
var msg = new Soup.Message("GET", this.cover_art_uri (id)); string id,
int size = -1,
int priority = Priority.DEFAULT,
Cancellable? cancellable = null
)
throws GLib.Error
{
var msg = new Soup.Message("GET", this.cover_art_uri (id, size));
assert (msg != null); assert (msg != null);
var stream = yield this.session.send_async (msg, Priority.DEFAULT, cancellable); var stream = yield this.session.send_async (msg, priority, cancellable);
assert (msg.get_status () == Soup.Status.OK); if (msg.get_status () != Soup.Status.OK) {
warning ("could not load cover art for %s: %s", id, Soup.Status.get_phrase (msg.get_status ()));
}
return yield new Gdk.Pixbuf.from_stream_async (stream, cancellable); return yield new Gdk.Pixbuf.from_stream_async (stream, cancellable);
} }
~Client () {
debug ("destroying subsonic client");
}
} }

27
src/subsonic/client.rs Normal file
View file

@ -0,0 +1,27 @@
mod ffi {
use gtk::glib;
#[repr(C)]
pub struct AudreySubsonicClient {
parent_instance: glib::gobject_ffi::GObject,
}
#[repr(C)]
pub struct AudreySubsonicClientClass {
parent_class: glib::gobject_ffi::GObjectClass,
}
extern "C" {
pub fn audrey_subsonic_client_get_type() -> glib::ffi::GType;
}
}
use gtk::glib;
glib::wrapper! {
pub struct Client(Object<ffi::AudreySubsonicClient, ffi::AudreySubsonicClientClass>);
match fn {
type_ => || ffi::audrey_subsonic_client_get_type(),
}
}

39
src/subsonic/song.rs Normal file
View file

@ -0,0 +1,39 @@
mod ffi {
use gtk::glib;
use std::ffi::*;
#[repr(C)]
#[derive(Copy, Clone)]
pub struct AudreySubsonicSong {
pub id: *mut c_char,
pub title: *mut c_char,
pub album: *mut c_char,
pub artist: *mut c_char,
pub track: i64,
pub year: i64,
pub starred: *mut glib::ffi::GDateTime,
pub duration: i64,
pub play_count: i64,
pub genre: *mut c_char,
pub cover_art: *mut c_char,
}
extern "C" {
pub fn audrey_subsonic_song_copy(ptr: *const AudreySubsonicSong)
-> *mut AudreySubsonicSong;
pub fn audrey_subsonic_song_free(ptr: *mut AudreySubsonicSong);
pub fn audrey_subsonic_song_get_type() -> glib::ffi::GType;
}
}
use gtk::glib;
glib::wrapper! {
pub struct Song(BoxedInline<ffi::AudreySubsonicSong>);
match fn {
copy => |ptr| ffi::audrey_subsonic_song_copy(ptr),
free => |ptr| ffi::audrey_subsonic_song_free(ptr),
type_ => || ffi::audrey_subsonic_song_get_type(),
}
}

5
src/ui.rs Normal file
View file

@ -0,0 +1,5 @@
mod window;
pub use window::Window;
mod playbar;
pub use playbar::Playbar;

View file

@ -1,12 +0,0 @@
blueprints = custom_target(
'blueprints',
input: files('play_queue.blp', 'play_queue_song.blp', 'setup.blp', 'window.blp'),
output: ['play_queue.ui', 'play_queue_song.ui', 'setup.ui', 'window.ui'],
command: [
find_program('blueprint-compiler'),
'batch-compile',
'@OUTDIR@',
'@CURRENT_SOURCE_DIR@',
'@INPUT@',
],
)

View file

@ -1,36 +0,0 @@
using Gtk 4.0;
using Adw 1;
template $UiPlayQueue: Adw.NavigationPage {
title: _("Play queue");
Adw.ToolbarView {
[top]
Adw.HeaderBar {
Button {
icon-name: "edit-clear-all";
clicked => $on_clear ();
sensitive: bind template.can_clear_all;
}
}
ScrolledWindow {
ListView view {
show-separators: true;
single-click-activate: true;
activate => $on_row_activated ();
model: NoSelection {
model: bind template.playbin as <$Playbin>.play_queue;
};
factory: SignalListItemFactory {
setup => $on_song_list_setup ();
bind => $on_song_list_bind ();
unbind => $on_song_list_unbind ();
};
}
}
}
}

2
src/ui/play_queue.rs Normal file
View file

@ -0,0 +1,2 @@
mod song;
pub use song::Song;

View file

@ -1,22 +1,28 @@
[GtkTemplate (ui = "/eu/callcc/audrey/ui/play_queue_song.ui")] // song widget+drag behavior taken from gnome music
class Ui.PlayQueueSong : Gtk.ListBoxRow {
[GtkTemplate (ui = "/eu/callcc/audrey/play_queue_song.ui")]
class Audrey.Ui.PlayQueueSong : Gtk.Box {
public bool draggable { get; set; default = false; }
public bool show_position { get; set; default = false; }
public bool show_artist { get; set; default = false; }
public bool show_cover { get; set; default = false; }
private bool _current = false;
public bool current { public bool current {
get { return _current; }
set { set {
this._current = value;
if (value) { if (value) {
this.play_icon_name = "media-playback-start";
this.add_css_class ("playing"); this.add_css_class ("playing");
} else { } else {
this.play_icon_name = "";
this.remove_css_class ("playing"); this.remove_css_class ("playing");
} }
} }
} }
public uint displayed_position { get; set; } public uint displayed_position { get; set; }
public Subsonic.Song song { get; set; } public PlaybinSong song { get; set; }
public string play_icon_name { get; set; default = ""; } private weak Playbin playbin;
private Playbin playbin;
public PlayQueueSong (Playbin playbin) { public PlayQueueSong (Playbin playbin) {
this.playbin = playbin; this.playbin = playbin;
@ -24,7 +30,7 @@ class Ui.PlayQueueSong : Gtk.ListBoxRow {
var remove = new SimpleAction ("remove", null); var remove = new SimpleAction ("remove", null);
remove.activate.connect (() => { remove.activate.connect (() => {
this.playbin.play_queue.remove (this.displayed_position-1); this.playbin.remove_track (this.displayed_position-1);
}); });
action_group.add_action (remove); action_group.add_action (remove);
@ -32,13 +38,15 @@ class Ui.PlayQueueSong : Gtk.ListBoxRow {
} }
private ulong connection; private ulong connection;
public void bind (uint position, Subsonic.Song song) { public void bind (uint position, PlaybinSong song) {
this.displayed_position = position+1; this.displayed_position = position+1;
this.song = song; this.song = song;
this.current = this.playbin.play_queue_position == position; this.current = this.playbin.play_queue_position == position;
this.connection = this.playbin.notify["play-queue-position"].connect (() => { this.connection = this.playbin.notify["play-queue-position"].connect (() => {
this.current = this.playbin.play_queue_position == position; this.current = this.playbin.play_queue_position == position;
}); });
song.need_cover_art ();
} }
public void unbind () { public void unbind () {
@ -53,20 +61,59 @@ class Ui.PlayQueueSong : Gtk.ListBoxRow {
return starred == null ? "non-starred" : "starred"; return starred == null ? "non-starred" : "starred";
} }
private double drag_x;
private double drag_y;
[GtkCallback] private Gdk.ContentProvider? on_drag_prepare (double x, double y) { [GtkCallback] private Gdk.ContentProvider? on_drag_prepare (double x, double y) {
if (this.draggable) {
this.drag_x = x;
this.drag_y = y;
return new Gdk.ContentProvider.for_value (this); return new Gdk.ContentProvider.for_value (this);
} }
else return null;
}
private Gtk.ListBox? drag_widget;
[GtkCallback] private void on_drag_begin (Gtk.DragSource source, Gdk.Drag drag) {
this.drag_widget = new Gtk.ListBox ();
var drag_row = new PlayQueueSong (this.playbin);
drag_row.draggable = false;
drag_row.show_position = this.show_position;
drag_row.show_artist = this.show_artist;
drag_row.show_cover = this.show_cover;
drag_row.current = false;
drag_row.displayed_position = this.displayed_position;
drag_row.song = this.song;
drag_row.set_size_request (this.get_width (), this.get_height ());
var drag_row_real = new Gtk.ListBoxRow ();
drag_row_real.child = drag_row;
this.drag_widget.append (drag_row_real);
this.drag_widget.drag_highlight_row (drag_row_real);
var drag_icon = Gtk.DragIcon.get_for_drag (drag);
drag_icon.set("child", this.drag_widget);
drag.set_hotspot ((int) this.drag_x, (int) this.drag_y);
}
[GtkCallback] private bool on_drop (Value value, double x, double y) { [GtkCallback] private bool on_drop (Value value, double x, double y) {
this.drag_widget = null;
this.drag_x = 0.0;
this.drag_y = 0.0;
var source = value as PlayQueueSong; var source = value as PlayQueueSong;
print ("dropped %u on %u", source.displayed_position, this.displayed_position); debug ("dropped %u on %u", source.displayed_position, this.displayed_position);
this.playbin.move_track (source.displayed_position-1, this.displayed_position-1);
return false; return false;
} }
} }
[GtkTemplate (ui = "/eu/callcc/audrey/ui/play_queue.ui")] [GtkTemplate (ui = "/eu/callcc/audrey/play_queue.ui")]
public class Ui.PlayQueue : Adw.NavigationPage { public class Audrey.Ui.PlayQueue : Adw.Bin {
private Playbin _playbin; private weak Playbin _playbin;
public Playbin playbin { public Playbin playbin {
get { return _playbin; } get { return _playbin; }
set { set {
@ -75,17 +122,14 @@ public class Ui.PlayQueue : Adw.NavigationPage {
_playbin.play_queue.items_changed.connect (this.on_store_items_changed); _playbin.play_queue.items_changed.connect (this.on_store_items_changed);
this.can_clear_all = _playbin.play_queue.get_n_items () > 0; this.can_clear_all = _playbin.play_queue.get_n_items () > 0;
_playbin.notify["play-queue-position"].connect (() => {
});
} }
} }
public bool can_clear_all { get; private set; } public bool can_clear_all { get; private set; }
[GtkCallback] private void on_clear () { /*[GtkCallback] private void on_clear () {
this.playbin.play_queue.remove_all (); this.playbin.clear ();
} }*/
private void on_store_items_changed (GLib.ListModel store, uint position, uint removed, uint added) { private void on_store_items_changed (GLib.ListModel store, uint position, uint removed, uint added) {
this.can_clear_all = store.get_n_items () > 0; this.can_clear_all = store.get_n_items () > 0;
@ -95,6 +139,11 @@ public class Ui.PlayQueue : Adw.NavigationPage {
var item = object as Gtk.ListItem; var item = object as Gtk.ListItem;
var child = new PlayQueueSong (this.playbin); var child = new PlayQueueSong (this.playbin);
child.draggable = true;
child.show_position = true;
child.show_artist = true;
child.show_cover = true;
item.child = child; item.child = child;
} }
@ -102,7 +151,7 @@ public class Ui.PlayQueue : Adw.NavigationPage {
var item = object as Gtk.ListItem; var item = object as Gtk.ListItem;
var child = item.child as PlayQueueSong; var child = item.child as PlayQueueSong;
child.bind (item.position, item.item as Subsonic.Song); child.bind (item.position, item.item as PlaybinSong);
} }
[GtkCallback] private void on_song_list_unbind (Gtk.SignalListItemFactory factory, Object object) { [GtkCallback] private void on_song_list_unbind (Gtk.SignalListItemFactory factory, Object object) {
@ -115,4 +164,12 @@ public class Ui.PlayQueue : Adw.NavigationPage {
[GtkCallback] private void on_row_activated (uint position) { [GtkCallback] private void on_row_activated (uint position) {
playbin.select_track (position); playbin.select_track (position);
} }
[GtkCallback] private string visible_child_name (uint n_items) {
return n_items > 0 ? "not-empty" : "empty";
}
~PlayQueue () {
debug ("destroying play queue widget");
}
} }

65
src/ui/play_queue/song.rs Normal file
View file

@ -0,0 +1,65 @@
mod imp {
use std::cell::RefCell;
//use crate::playbin;
use gtk::{glib, prelude::*, subclass::prelude::*};
#[derive(gtk::CompositeTemplate, glib::Properties, Default)]
#[template(resource = "/eu/callcc/audrey/play_queue_song.ui")]
#[properties(wrapper_type = super::Song)]
pub struct Song {
#[property(get, set, default = false)]
draggable: RefCell<bool>,
#[property(get, set, default = false)]
show_position: RefCell<bool>,
#[property(get, set, default = false)]
show_artist: RefCell<bool>,
#[property(get, set, default = false)]
show_cover: RefCell<bool>,
#[property(get, set = Self::set_current, default = false)]
current: RefCell<bool>,
#[property(get, set)]
displayed_position: RefCell<u32>,
//#[property(get, set)]
//song: playbin::Song,
//playbin: playbin::Playbin,
connection: RefCell<u64>,
drag_x: RefCell<f64>,
drag_y: RefCell<f64>,
drag_widget: RefCell<Option<gtk::ListBox>>,
}
#[glib::object_subclass]
impl ObjectSubclass for Song {
const NAME: &'static str = "AudreyUiPlayQueueSong";
type Type = super::Song;
type ParentType = gtk::Box;
}
#[glib::derived_properties]
impl ObjectImpl for Song {}
impl WidgetImpl for Song {}
impl BoxImpl for Song {}
#[gtk::template_callbacks]
impl Song {
fn set_current(&self, value: bool) {
*self.current.borrow_mut() = value;
if value {
self.obj().add_css_class("playing");
} else {
self.obj().remove_css_class("playing");
}
}
}
}
use gtk::glib;
glib::wrapper! {
pub struct Song(ObjectSubclass<imp::Song>)
@extends gtk::Box, gtk::Widget,
@implements gtk::Accessible, gtk::Buildable, gtk::ConstraintTarget, gtk::Orientable;
}

View file

@ -1,145 +0,0 @@
using Gtk 4.0;
template $UiPlayQueueSong: ListBoxRow {
selectable: false;
Box {
focusable: false;
spacing: 6;
Image {
//visible: false;
icon-name: "list-drag-handle";
styles [ "drag-handle" ]
}
Box {
width-request: 48;
focusable: false;
homogeneous: true;
Image {
focusable: false;
icon-size: normal;
icon-name: bind template.play-icon-name;
}
Label {
focusable: false;
halign: end;
justify: right;
styles [ "dim-label", "numeric" ]
label: bind template.displayed_position;
}
}
Box title_box {
focusable: false;
hexpand: true;
Label {
focusable: false;
xalign: 0;
halign: start;
hexpand: true;
ellipsize: end;
max-width-chars: 90;
justify: fill;
margin-start: 9;
label: bind template.song as <$SubsonicSong>.title;
styles [ "title-label" ]
}
}
Box artist_box {
//visible: false;
focusable: false;
hexpand: true;
Label {
focusable: false;
xalign: 0;
halign: start;
hexpand: true;
ellipsize: end;
max-width-chars: 90;
justify: fill;
margin-start: 9;
label: bind template.song as <$SubsonicSong>.artist;
}
}
Box album_duration_box {
focusable: false;
hexpand: true;
spacing: 6;
Label {
//visible: false;
focusable: false;
xalign: 0;
halign: start;
hexpand: true;
ellipsize: end;
max-width-chars: 90;
justify: fill;
label: bind template.song as <$SubsonicSong>.album;
}
Label {
focusable: false;
halign: end;
hexpand: true;
single-line-mode: true;
styles [ "numeric" ]
label: bind $format_duration (template.song as <$SubsonicSong>.duration) as <string>;
}
}
Button {
focusable: true;
icon-name: bind $star_button_icon_name (template.song as <$SubsonicSong>.starred) as <string>;
styles [ "flat" ]
}
MenuButton {
//visible: false;
focusable: true;
icon-name: "view-more";
styles [ "flat" ]
popover: PopoverMenu {
menu-model: song-menu;
};
}
}
DragSource {
actions: move;
propagation-phase: capture;
prepare => $on_drag_prepare ();
}
DropTarget {
actions: move;
formats: UiPlayQueueSong;
preload: true;
drop => $on_drop ();
}
}
SizeGroup {
mode: horizontal;
widgets [title_box, artist_box, album_duration_box]
}
menu song-menu {
item ("Remove", "song.remove")
}

1
src/ui/playbar.h Normal file
View file

@ -0,0 +1 @@
typedef struct _AudreyUiPlaybar AudreyUiPlaybar;

203
src/ui/playbar.rs Normal file
View file

@ -0,0 +1,203 @@
mod imp {
use adw::prelude::*;
use adw::subclass::prelude::*;
use glib::subclass::InitializingObject;
use gtk::{gdk, glib};
use std::cell::{Cell, RefCell};
#[derive(glib::Properties, gtk::CompositeTemplate, Default)]
#[properties(wrapper_type = super::Playbar)]
#[template(resource = "/eu/callcc/audrey/playbar.ui")]
pub struct Playbar {
#[property(get, set)]
song: RefCell<Option<crate::playbin::Song>>,
#[property(get, set)]
playing_cover_art: RefCell<Option<gdk::Paintable>>,
#[property(get, set)]
playbin: RefCell<Option<crate::Playbin>>, // TODO: weak
#[property(get, set, default = true)]
show_cover_art: Cell<bool>,
#[property(get = Self::get_volume, set = Self::set_volume)]
_volume: i32,
}
#[glib::object_subclass]
impl ObjectSubclass for Playbar {
const NAME: &'static str = "AudreyUiPlaybar";
type Type = super::Playbar;
type ParentType = adw::Bin;
fn class_init(klass: &mut Self::Class) {
klass.bind_template();
klass.bind_template_callbacks();
}
fn instance_init(obj: &InitializingObject<Self>) {
obj.init_template();
}
}
#[glib::derived_properties]
impl ObjectImpl for Playbar {}
impl WidgetImpl for Playbar {}
impl BinImpl for Playbar {}
#[gtk::template_callbacks]
impl Playbar {
#[template_callback]
fn song_title(&self, song: Option<&crate::playbin::Song>) -> String {
match song {
None => "".to_owned(),
Some(song) => song.get_title().to_string(),
}
}
#[template_callback]
fn song_artist(&self, song: Option<&crate::playbin::Song>) -> String {
match song {
None => "".to_owned(),
Some(song) => song.get_artist().to_string(),
}
}
#[template_callback]
fn song_album(&self, song: Option<&crate::playbin::Song>) -> String {
match song {
None => "".to_owned(),
Some(song) => song.get_album().to_string(),
}
}
#[template_callback]
fn format_timestamp(&self, s: f64) -> String {
format!("{:02}:{:02}", (s as i64) / 64, (s as i64) % 60)
}
#[template_callback]
fn playbin_active(&self, state: crate::playbin::State) -> bool {
state != crate::playbin::State::Stopped
}
#[template_callback]
fn can_press_play(&self, state: crate::playbin::State, n_items: u32) -> bool {
!(state == crate::playbin::State::Stopped && n_items == 0)
}
#[template_callback]
fn play_pause_icon_name(&self, state: crate::playbin::State) -> &'static str {
match state {
crate::playbin::State::Playing => "media-playback-pause",
_ => "media-playback-start",
}
}
#[template_callback]
fn mute_button_icon_name(&self, mute: bool) -> &'static str {
if mute {
"audio-volume-muted"
} else {
"audio-volume-high"
}
}
#[template_callback]
fn on_play_position_seek(
&self,
_scroll_type: gtk::ScrollType,
value: f64,
range: &gtk::Range,
) -> bool {
let playbin = self.playbin.borrow();
let playbin = playbin.as_ref().unwrap();
if range.adjustment().lower() < range.adjustment().upper() {
playbin.seek(value);
}
false
}
#[template_callback]
fn on_skip_forward_clicked(&self) {
// this.playbin.go_to_next_track ();
todo!()
}
#[template_callback]
fn on_skip_backward_clicked(&self) {
// this.playbin.go_to_prev_track ();
todo!()
}
#[template_callback]
fn seek_backward(&self) {
// 10 seconds
// double new_position = playbin.position - 10.0;
// if (new_position < 0.0) new_position = 0.0;
// this.playbin.seek (new_position);
todo!()
}
#[template_callback]
fn seek_forward(&self) {
// 10 seconds
// double new_position = playbin.position + 10.0;
// if (new_position > this.playbin.duration) new_position = this.playbin.duration;
// this.playbin.seek (new_position);
todo!()
}
#[template_callback]
fn on_play_pause_clicked(&self, _button: &gtk::Button) {
let playbin = self.playbin.borrow();
let playbin = playbin.as_ref().unwrap();
if playbin.get_state() == crate::playbin::State::Playing {
playbin.pause();
} else {
playbin.play();
}
}
#[template_callback]
fn on_mute_toggle(&self) {
//this.playbin.mute = !this.playbin.mute;
todo!()
}
fn get_volume(&self) -> i32 {
let playbin = self.playbin.borrow();
match playbin.as_ref() {
None => 100,
Some(playbin) => playbin.get_volume(),
}
}
fn set_volume(&self, value: i32) {
let playbin = self.playbin.borrow();
let playbin = playbin.as_ref().unwrap();
playbin.set_volume(value);
}
}
}
use gtk::glib;
glib::wrapper! {
pub struct Playbar(ObjectSubclass<imp::Playbar>)
@extends adw::Bin, gtk::Widget,
@implements gtk::Accessible, gtk::Buildable, gtk::ConstraintTarget;
}
mod ffi {
use glib::translate::IntoGlib;
use gtk::glib;
use gtk::prelude::*;
#[no_mangle]
pub extern "C" fn audrey_ui_playbar_get_type() -> glib::ffi::GType {
super::Playbar::static_type().into_glib()
}
}

100
src/ui/playbar.vapi Normal file
View file

@ -0,0 +1,100 @@
// [GtkTemplate (ui = "/eu/callcc/audrey/playbar.ui")]
[CCode (cheader_filename = "ui/playbar.h")]
public class Audrey.Ui.Playbar : Adw.Bin {
public PlaybinSong? song {
get;
set;
}
public Gdk.Paintable? playing_cover_art { get; set; }
public weak Playbin playbin { get; set; }
public bool show_cover_art { get; set; /*default = true;*/ }
public int volume { get; set; }
/*{
get { return playbin == null ? 100 : playbin.volume; }
set { playbin.volume = value; }
}*/
/*
[GtkCallback] private string format_timestamp (double s) {
return "%02d:%02d".printf (((int) s)/60, ((int) s)%60);
}
[GtkCallback] private bool on_play_position_seek (Gtk.Range range, Gtk.ScrollType scroll_type, double value) {
if (range.adjustment.lower < range.adjustment.upper) {
this.playbin.seek ((int64) value);
}
return false;
}
[GtkCallback] private void on_play_pause_clicked () {
if (this.playbin.state == PlaybinState.PLAYING) {
this.playbin.pause();
} else {
this.playbin.play();
}
}
[GtkCallback] private string play_pause_icon_name (PlaybinState state) {
if (state == PlaybinState.PLAYING) {
return "media-playback-pause";
} else {
return "media-playback-start";
}
}
[GtkCallback] private bool playbin_active (PlaybinState state) {
return state != PlaybinState.STOPPED;
}
[GtkCallback] private bool can_press_play (PlaybinState state, uint n_items) {
return !(state == PlaybinState.STOPPED && n_items == 0);
}
[GtkCallback] private string mute_button_icon_name (bool mute) {
return mute ? "audio-volume-muted" : "audio-volume-high";
}
[GtkCallback] private void on_mute_toggle () {
this.playbin.mute = !this.playbin.mute;
}
[GtkCallback] private void on_skip_forward_clicked () {
this.playbin.go_to_next_track ();
}
[GtkCallback] private void on_skip_backward_clicked () {
this.playbin.go_to_prev_track ();
}
[GtkCallback] private void seek_backward () {
// 10 seconds
double new_position = playbin.position - 10.0;
if (new_position < 0.0) new_position = 0.0;
this.playbin.seek (new_position);
}
[GtkCallback] private void seek_forward () {
// 10 seconds
double new_position = playbin.position + 10.0;
if (new_position > this.playbin.duration) new_position = this.playbin.duration;
this.playbin.seek (new_position);
}
[GtkCallback] private string song_title (PlaybinSong? song) {
return song == null ? "" : song.title;
}
[GtkCallback] private string song_artist (PlaybinSong? song) {
return song == null ? "" : song.artist;
}
[GtkCallback] private string song_album (PlaybinSong? song) {
return song == null ? "" : song.album;
}
~Playbar () {
debug ("destroying playbar widget");
}
*/
}

View file

@ -16,8 +16,8 @@ static void salt_password (string password, out string token, out string salt) {
salt = (string) salt_chars; salt = (string) salt_chars;
} }
[GtkTemplate (ui = "/eu/callcc/audrey/ui/setup.ui")] [GtkTemplate (ui = "/eu/callcc/audrey/setup.ui")]
public class Ui.Setup : Adw.PreferencesDialog { public class Audrey.Ui.Setup : Adw.PreferencesDialog {
public string status { get; private set; default = _("Not connected"); } public string status { get; private set; default = _("Not connected"); }
public bool authn_can_edit { get; private set; default = true; } public bool authn_can_edit { get; private set; default = true; }
@ -78,7 +78,7 @@ public class Ui.Setup : Adw.PreferencesDialog {
Secret.password_searchv.begin ( Secret.password_searchv.begin (
secret_schema, secret_schema,
new HashTable<string, string> (null, null), new HashTable<string, string> (null, null),
Secret.SearchFlags.NONE, Secret.SearchFlags.UNLOCK,
null, null,
(obj, res) => { (obj, res) => {
try { try {
@ -128,4 +128,8 @@ public class Ui.Setup : Adw.PreferencesDialog {
this.authn_can_edit = true; this.authn_can_edit = true;
}, "server-url", this.server_url, "username", this.username); }, "server-url", this.server_url, "username", this.username);
} }
~Setup () {
debug ("destroying setup dialog");
}
} }

View file

@ -1,250 +0,0 @@
using Gtk 4.0;
using Adw 1;
template $UiWindow: Adw.ApplicationWindow {
title: _("audrey");
default-width: 800;
default-height: 600;
content: Adw.BottomSheet bottom_sheet {
can-open: false; // broken in libadwaita
[content]
Adw.OverlaySplitView {
margin-bottom: bind bottom_sheet.bottom-bar-height;
vexpand: true;
[sidebar]
Adw.NavigationPage {
width-request: 100;
title: _("audrey");
Adw.ToolbarView {
[top]
Adw.HeaderBar {
[end]
Button {
icon-name: "applications-system";
clicked => $show_setup_dialog ();
}
}
content: Box {
orientation: vertical;
ListBox sidebar {
styles [
"navigation-sidebar",
]
row-activated => $on_sidebar_row_activated();
ListBoxRow sidebar_play_queue {
Label {
xalign: 0;
label: _("Play queue");
}
}
}
Separator {}
ListBox {
selection-mode: none;
styles [
"navigation-sidebar",
]
Adw.ButtonRow shuffle_all_tracks {
title: _("Shuffle all tracks");
start-icon-name: "media-playlist-shuffle";
sensitive: false;
}
}
Separator {
styles [
"spacer",
]
vexpand: true;
}
Picture {
paintable: bind template.playing_cover_art;
}
};
}
}
[content]
Stack stack {
StackPage {
name: "play_queue";
title: _("Play queue");
child: $UiPlayQueue play_queue {
playbin: bind template.playbin;
};
}
}
}
[sheet]
Box {}
[bottom-bar]
CenterBox {
styles [
"toolbar",
]
[start]
Box {
orientation: vertical;
valign: center;
Label {
styles [ "heading" ]
xalign: 0;
halign: start;
label: bind $song_title (template.song) as <string>;
ellipsize: end;
}
Label {
styles [ "caption" ]
xalign: 0;
label: bind $song_artist (template.song) as <string>;
ellipsize: end;
}
Label {
styles [ "caption" ]
xalign: 0;
label: bind $song_album (template.song) as <string>;
ellipsize: end;
}
}
[center]
Box {
orientation: vertical;
halign: center;
hexpand: true;
CenterBox {
[start]
Label play_position_label {
styles [
"caption",
"numeric",
]
label: bind $format_timestamp (template.playbin as <$Playbin>.position) as <string>;
}
[center]
Scale play_position {
name: "seek-scale";
orientation: horizontal;
width-request: 400;
sensitive: bind $playbin_active (template.playbin as <$Playbin>.state as <$PlaybinState>) as <bool>;
adjustment: Adjustment {
lower: 0;
value: bind template.playbin as <$Playbin>.position;
upper: bind template.playbin as <$Playbin>.duration;
};
change-value => $on_play_position_seek ();
}
[end]
Label play_duration {
styles [
"caption",
"numeric",
]
label: bind $format_timestamp (template.playbin as <$Playbin>.duration) as <string>;
}
}
Box {
halign: center;
orientation: horizontal;
Button {
icon-name: "media-skip-backward";
valign: center;
sensitive: bind $playbin_active (template.playbin as <$Playbin>.state as <$PlaybinState>) as <bool>;
clicked => $on_skip_backward_clicked ();
}
Button {
icon-name: "media-seek-backward";
valign: center;
sensitive: bind $playbin_active (template.playbin as <$Playbin>.state as <$PlaybinState>) as <bool>;
clicked => $seek_backward ();
}
Button {
icon-name: bind $play_pause_icon_name (template.playbin as <$Playbin>.state as <$PlaybinState>) as <string>;
valign: center;
sensitive: bind $playbin_active (template.playbin as <$Playbin>.state as <$PlaybinState>) as <bool>;
clicked => $on_play_pause_clicked ();
}
Button {
icon-name: "media-seek-forward";
valign: center;
sensitive: bind $playbin_active (template.playbin as <$Playbin>.state as <$PlaybinState>) as <bool>;
clicked => $seek_forward ();
}
Button {
icon-name: "media-skip-forward";
valign: center;
sensitive: bind $playbin_active (template.playbin as <$Playbin>.state as <$PlaybinState>) as <bool>;
clicked => $on_skip_forward_clicked ();
}
}
}
[end]
Box {
Button {
icon-name: "non-starred";
valign: center;
}
Button {
icon-name: bind $mute_button_icon_name (template.mute) as <string>;
valign: center;
clicked => $on_mute_toggle ();
}
Scale {
name: "volume-scale";
orientation: horizontal;
width-request: 130;
adjustment: Adjustment {
lower: 0;
value: bind template.volume bidirectional;
upper: 100;
};
}
}
}
};
}

39
src/ui/window.rs Normal file
View file

@ -0,0 +1,39 @@
mod ffi {
use gtk::glib;
#[repr(C)]
pub struct AudreyUiWindow {
parent_instance: adw::ffi::AdwApplicationWindow,
}
#[repr(C)]
pub struct AudreyUiWindowClass {
parent_class: adw::ffi::AdwApplicationWindowClass,
}
extern "C" {
pub fn audrey_ui_window_get_type() -> glib::ffi::GType;
pub fn audrey_ui_window_new(app: *mut gtk::ffi::GtkApplication) -> *mut AudreyUiWindow;
}
}
use adw::prelude::*;
use gtk::{gio, glib};
glib::wrapper! {
pub struct Window(Object<ffi::AudreyUiWindow, ffi::AudreyUiWindowClass>)
@extends adw::ApplicationWindow, gtk::ApplicationWindow, gtk::Window, gtk::Widget,
@implements gio::ActionGroup, gio::ActionMap, gtk::Accessible, gtk::Buildable, gtk::ConstraintTarget, gtk::Native, gtk::Root, gtk::ShortcutManager;
match fn {
type_ => || ffi::audrey_ui_window_get_type(),
}
}
impl Window {
pub fn new(app: &impl IsA<gtk::Application>) -> Self {
use glib::translate::*;
unsafe { from_glib_none(ffi::audrey_ui_window_new(app.as_ref().to_glib_none().0)) }
}
}

View file

@ -1,11 +1,8 @@
[GtkTemplate (ui = "/eu/callcc/audrey/ui/window.ui")] [GtkTemplate (ui = "/eu/callcc/audrey/window.ui")]
class Ui.Window : Adw.ApplicationWindow { class Audrey.Ui.Window : Adw.ApplicationWindow {
[GtkChild] private unowned Gtk.ListBox sidebar; [GtkChild] public unowned PlayQueue play_queue;
[GtkChild] private unowned Gtk.ListBoxRow sidebar_play_queue; [GtkChild] public unowned Playbar playbar;
[GtkChild] private unowned Gtk.Stack stack; //[GtkChild] public unowned Adw.ButtonRow shuffle_all_tracks;
[GtkChild] public unowned Ui.PlayQueue play_queue;
[GtkChild] public unowned Adw.ButtonRow shuffle_all_tracks;
private Setup setup; private Setup setup;
@ -20,11 +17,11 @@ class Ui.Window : Adw.ApplicationWindow {
set { this.playbin.mute = value; } set { this.playbin.mute = value; }
} }
public Subsonic.Song? song { get; private set; } public PlaybinSong? song { get; private set; }
public Gdk.Paintable? playing_cover_art { get; set; default = null; }
private Cancellable cancel_loading_art; private Cancellable cancel_loading_art;
public bool cover_art_loading { get; set; default = false; } public bool cover_art_loading { get; set; default = false; }
public Gdk.Paintable playing_cover_art { get; set; }
public Playbin playbin { get; private set; default = new Playbin (); } public Playbin playbin { get; private set; default = new Playbin (); }
@ -32,42 +29,23 @@ class Ui.Window : Adw.ApplicationWindow {
Object (application: app); Object (application: app);
} }
construct { private Mpris mpris;
// TODO: mpris private MprisPlayer mpris_player;
// Bus.own_name (
// BusType.SESSION,
// "org.mpris.MediaPlayer2.audrey",
// BusNameOwnerFlags.NONE,
// (conn) => {
// try {
// } catch (IOError e) {
// error ("could not register dbus service: %s", e.message);
// }
// },
// () => {},
// () => { error ("could not acquire dbus name"); });
this.setup = new Setup (); private void now_playing (PlaybinSong song) {
this.song = song;
this.setup.connected.connect ((api) => { // api.scrobble.begin (this.song.id); TODO
this.api = api;
this.playbin.api = api;
this.playbin.now_playing.connect ((playbin, now, next) => {
this.song = now;
api.scrobble.begin (this.song.id);
if (this.cancel_loading_art != null) { if (this.cancel_loading_art != null) {
this.cancel_loading_art.cancel (); this.cancel_loading_art.cancel ();
} }
this.cancel_loading_art = new GLib.Cancellable (); this.cancel_loading_art = new GLib.Cancellable ();
this.playing_cover_art = Gdk.Paintable.empty (1, 1); this.playing_cover_art = null; // TODO: preload next art somehow
if (this.song != null) {
this.cover_art_loading = true; this.cover_art_loading = true;
string song_id = this.song.id; string song_id = this.song.id;
this.api.cover_art.begin (song_id, this.cancel_loading_art, (obj, res) => { this.api.cover_art.begin (song_id, -1, Priority.DEFAULT, this.cancel_loading_art, (obj, res) => {
try { try {
this.playing_cover_art = Gdk.Texture.for_pixbuf (this.api.cover_art.end (res)); this.playing_cover_art = Gdk.Texture.for_pixbuf (this.api.cover_art.end (res));
this.cover_art_loading = false; this.cover_art_loading = false;
@ -79,114 +57,80 @@ class Ui.Window : Adw.ApplicationWindow {
} }
}); });
} }
});
this.playbin.stopped.connect (() => { construct {
this.playing_cover_art = Gdk.Paintable.empty (1, 1); Bus.own_name (
this.song = null; BusType.SESSION,
}); "org.mpris.MediaPlayer2.audrey",
BusNameOwnerFlags.NONE,
this.shuffle_all_tracks.sensitive = true; (conn) => {
this.shuffle_all_tracks.activated.connect (() => {
this.shuffle_all_tracks.sensitive = false;
this.playbin.play_queue.remove_all ();
api.get_random_songs.begin (null, (song) => {
this.playbin.play_queue.append (song);
}, (obj, res) => {
try { try {
api.get_random_songs.end (res); this.mpris = new Mpris (this);
} catch (Error e) { this.mpris_player = new MprisPlayer (conn, this.playbin);
error ("could not get random songs: %s", e.message);
}
this.shuffle_all_tracks.sensitive = true;
this.playbin.select_track (0); conn.register_object ("/org/mpris/MediaPlayer2", this.mpris);
}); conn.register_object ("/org/mpris/MediaPlayer2", this.mpris_player);
}); } catch (IOError e) {
error ("could not register dbus service: %s", e.message);
}
},
() => {},
() => { error ("could not acquire dbus name"); });
this.setup = new Setup ();
this.setup.connected.connect ((api) => {
this.api = api;
this.playbin.api = api;
this.mpris_player.api = api;
this.can_click_shuffle_all = true;
}); });
this.setup.load (); this.setup.load ();
this.sidebar.select_row (this.sidebar.get_row_at_index (0)); this.playbin.new_track.connect (() => {
} this.now_playing (this.playbin.play_queue.get_item (this.playbin.play_queue_position) as PlaybinSong);
});
[GtkCallback] private void on_sidebar_row_activated (Gtk.ListBoxRow row) { this.playbin.stopped.connect (() => {
if (row == this.sidebar_play_queue) { this.playing_cover_art = null;
this.stack.set_visible_child_name("play_queue"); this.song = null;
} });
}
[GtkCallback] private string format_timestamp (double s) {
return "%02d:%02d".printf (((int) s)/60, ((int) s)%60);
}
[GtkCallback] private bool on_play_position_seek (Gtk.Range range, Gtk.ScrollType scroll_type, double value) {
this.playbin.seek ((int64) value);
return false;
}
[GtkCallback] private void on_play_pause_clicked () {
if (this.playbin.state == PlaybinState.PLAYING) {
this.playbin.pause();
} else {
this.playbin.play();
}
}
[GtkCallback] private string play_pause_icon_name (PlaybinState state) {
if (state == PlaybinState.PLAYING) {
return "media-playback-pause";
} else {
return "media-playback-start";
}
}
[GtkCallback] private bool playbin_active (PlaybinState state) {
return state != PlaybinState.STOPPED;
}
[GtkCallback] private string mute_button_icon_name (bool mute) {
return mute ? "audio-volume-muted" : "audio-volume-high";
}
[GtkCallback] private void on_mute_toggle () {
this.mute = !this.mute;
}
[GtkCallback] private void on_skip_forward_clicked () {
this.playbin.next_track ();
}
[GtkCallback] private void on_skip_backward_clicked () {
this.playbin.prev_track ();
} }
[GtkCallback] private void show_setup_dialog () { [GtkCallback] private void show_setup_dialog () {
this.setup.present (this); this.setup.present (this);
} }
[GtkCallback] private void seek_backward () { public bool can_click_shuffle_all { get; private set; default = false; }
// 10 seconds
double new_position = playbin.position - 10.0; [GtkCallback] private void shuffle_all () {
if (new_position < 0.0) new_position = 0.0; this.can_click_shuffle_all = false;
this.playbin.seek (new_position); this.playbin.clear ();
api.get_random_songs.begin (null, (song) => {
this.playbin.append_track (song);
}, (obj, res) => {
try {
api.get_random_songs.end (res);
} catch (Error e) {
error ("could not get random songs: %s", e.message);
}
this.can_click_shuffle_all = true;
this.playbin.select_track (0);
});
} }
[GtkCallback] private void seek_forward () { [GtkCallback] private bool show_playbar_cover_art (string? stack_child) {
// 10 seconds return stack_child != "play-queue";
double new_position = playbin.position + 10.0;
if (new_position > this.playbin.duration) new_position = this.playbin.duration;
this.playbin.seek (new_position);
} }
[GtkCallback] private string song_title (Subsonic.Song? song) { public override bool close_request () {
return song == null ? "" : song.title; // stop playback on close
this.playbin.stop ();
return false;
} }
[GtkCallback] private string song_artist (Subsonic.Song? song) { ~Window () {
return song == null ? "" : song.artist; debug ("destroying main window");
}
[GtkCallback] private string song_album (Subsonic.Song? song) {
return song == null ? "" : song.album;
} }
} }

View file

@ -31,7 +31,7 @@ namespace Mpv {
public delegate void WakeupCallback (); public delegate void WakeupCallback ();
[CCode (cname = "mpv_handle", free_function = "mpv_destroy")] [CCode (cname = "mpv_handle", free_function = "mpv_terminate_destroy")]
[Compact] [Compact]
public class Handle { public class Handle {
[CCode (cname = "mpv_create")] [CCode (cname = "mpv_create")]
@ -65,6 +65,9 @@ namespace Mpv {
[CCode (cname = "mpv_command")] [CCode (cname = "mpv_command")]
public Error command ([CCode (array_length = false)] string[] args); public Error command ([CCode (array_length = false)] string[] args);
[CCode (cname = "mpv_command_async")]
public Error command_async (uint64 reply_userdata, [CCode (array_length = false)] string[] args);
[CCode (cname = "mpv_observe_property")] [CCode (cname = "mpv_observe_property")]
public Error observe_property (uint64 reply_userdata, string name, Format format); public Error observe_property (uint64 reply_userdata, string name, Format format);
} }
@ -101,7 +104,10 @@ namespace Mpv {
PLAYBACK_RESTART, PLAYBACK_RESTART,
PROPERTY_CHANGE, PROPERTY_CHANGE,
QUEUE_OVERFLOW, QUEUE_OVERFLOW,
HOOK, HOOK;
[CCode (cname = "mpv_event_name")]
public unowned string to_string ();
} }
[CCode (cname = "mpv_event", destroy_function = "", has_type_id = false, has_copy_function = false)] [CCode (cname = "mpv_event", destroy_function = "", has_type_id = false, has_copy_function = false)]
@ -143,6 +149,12 @@ namespace Mpv {
{ {
return * (double *) data; return * (double *) data;
} }
public bool parse_flag ()
requires (format == Format.FLAG)
{
return (* (int *) data) == 1;
}
} }
[CCode (cname = "mpv_event_end_file", destroy_function = "", has_type_id = false, has_copy_function = false)] [CCode (cname = "mpv_event_end_file", destroy_function = "", has_type_id = false, has_copy_function = false)]