Compare commits
90 commits
Author | SHA1 | Date | |
---|---|---|---|
f8015fbe10 | |||
bf8b02de12 | |||
0f2351ae01 | |||
6f3b3537ad | |||
b94d41542f | |||
07163eb4f3 | |||
c98290d4f0 | |||
427e42a087 | |||
4ee544a88d | |||
ec2167f8fe | |||
66467ee8af | |||
bb097cd3d5 | |||
4ca473eb11 | |||
e02bc10ebc | |||
77cb665da8 | |||
6def98af81 | |||
59eba426d2 | |||
a8453e6e3d | |||
879dc39c27 | |||
9e5d23061c | |||
38f1ce63d3 | |||
2bba51b97b | |||
2f70f61547 | |||
1f289ecf1e | |||
4193fb72c2 | |||
5bfaeade17 | |||
5e2d2807c1 | |||
36b2cf989d | |||
66500f483c | |||
824a68956a | |||
1ec9d1c2eb | |||
68d1d78216 | |||
7799566758 | |||
e925dc33cc | |||
a5091d42e0 | |||
dfe4a24c5b | |||
713b3d8842 | |||
edaf7079ac | |||
3f7f7d4ce9 | |||
77f9d70d29 | |||
6a2b157c7d | |||
f240424774 | |||
a82b5b0475 | |||
175563ba76 | |||
8e5ac49417 | |||
347cb55c9d | |||
efc639367b | |||
3772be599b | |||
8c4c4f8e74 | |||
36d6734e73 | |||
d550d8f9b7 | |||
faa5d15e1e | |||
9f6bc7b10b | |||
d666c8fb49 | |||
41b32aa4cf | |||
64dcceea22 | |||
72d4e63249 | |||
2b2ace0f5c | |||
f436557bf5 | |||
fdd719f4f8 | |||
48795a4d2f | |||
41bfab9ab8 | |||
a556fe7e29 | |||
af4da894fc | |||
fa9d3b873f | |||
2f96d2216d | |||
83b2db4b76 | |||
75cb222b91 | |||
734ffc5758 | |||
9ba12bf365 | |||
c6446f4352 | |||
b53801c470 | |||
64744819de | |||
f09a89140d | |||
d2025102e6 | |||
26449b9dcf | |||
5c24cde637 | |||
af127b8d7b | |||
c880729d19 | |||
0fd5298a31 | |||
f97b8ab17c | |||
35394d74ac | |||
eec61c8ed0 | |||
c8e4e55410 | |||
4c9f24cd6b | |||
f78268c013 | |||
73a626e5a0 | |||
5214daf4e7 | |||
800bff6e1a | |||
cc525dcc80 |
50 changed files with 3452 additions and 810 deletions
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
|
@ -0,0 +1 @@
|
|||
target/
|
1034
Cargo.lock
generated
Normal file
1034
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
16
Cargo.toml
Normal file
16
Cargo.toml
Normal 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
21
build.rs
Normal 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");
|
||||
}
|
49
meson.build
49
meson.build
|
@ -8,10 +8,10 @@ project(
|
|||
|
||||
i18n = import('i18n')
|
||||
gnome = import('gnome')
|
||||
fs = import('fs')
|
||||
cc = meson.get_compiler('c')
|
||||
valac = meson.get_compiler('vala')
|
||||
|
||||
|
||||
srcdir = meson.project_source_root() / 'src'
|
||||
|
||||
config_h = configuration_data()
|
||||
|
@ -38,13 +38,48 @@ add_project_arguments(
|
|||
language: 'c',
|
||||
)
|
||||
|
||||
|
||||
subdir('data')
|
||||
subdir('src')
|
||||
subdir('po')
|
||||
subdir('resources')
|
||||
subdir('src')
|
||||
|
||||
gnome.post_install(
|
||||
glib_compile_schemas: true,
|
||||
gtk_update_icon_cache: true,
|
||||
update_desktop_database: true,
|
||||
if get_option('buildtype') == 'debug'
|
||||
rust_args = []
|
||||
rust_target = 'debug'
|
||||
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)
|
||||
|
|
12
resources/audrey.gresource.xml
Normal file
12
resources/audrey.gresource.xml
Normal 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
31
resources/meson.build
Normal 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
46
resources/play_queue.blp
Normal 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 ();
|
||||
};
|
||||
}
|
||||
};
|
||||
}
|
||||
};
|
||||
}
|
132
resources/play_queue_song.blp
Normal file
132
resources/play_queue_song.blp
Normal 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
172
resources/playbar.blp
Normal 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;
|
||||
};
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
|
@ -1,7 +1,7 @@
|
|||
using Gtk 4.0;
|
||||
using Adw 1;
|
||||
|
||||
template $UiSetup: Adw.PreferencesDialog {
|
||||
template $AudreyUiSetup: Adw.PreferencesDialog {
|
||||
title: _("Setup");
|
||||
|
||||
Adw.ToolbarView {
|
|
@ -1,3 +1,11 @@
|
|||
/*
|
||||
#play-queue-page {
|
||||
background-image: linear-gradient(
|
||||
var(--window-bg-color),
|
||||
var(--accent-bg-color));
|
||||
}
|
||||
*/
|
||||
|
||||
#seek-scale slider {
|
||||
margin: 0px;
|
||||
opacity: 0%;
|
||||
|
@ -11,6 +19,6 @@
|
|||
min-height: 15px;
|
||||
}
|
||||
|
||||
.playing .title-label {
|
||||
#play-queue .playing label.title {
|
||||
font-weight: bold;
|
||||
}
|
136
resources/window.blp
Normal file
136
resources/window.blp
Normal 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
55
src/application.rs
Normal 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()
|
||||
}
|
||||
}
|
|
@ -41,4 +41,8 @@ public class Audrey.Application : Adw.Application {
|
|||
private void on_preferences_action () {
|
||||
message ("app.preferences action activated");
|
||||
}
|
||||
|
||||
~Application () {
|
||||
debug ("destroying application");
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
41
src/main.rs
Normal 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()
|
||||
}
|
|
@ -1,17 +1,16 @@
|
|||
audrey_sources = [
|
||||
'application.vala',
|
||||
'globalconf.vala',
|
||||
'main.vala',
|
||||
'mpris.vala',
|
||||
'playbin.vala',
|
||||
'subsonic.vala',
|
||||
'ui/play_queue.vala',
|
||||
'ui/playbar.vapi',
|
||||
'ui/setup.vala',
|
||||
'ui/window.vala',
|
||||
]
|
||||
|
||||
audrey_deps = [
|
||||
config_dep,
|
||||
dependency('gtk4', version: '>= 4.16'),
|
||||
dependency('json-glib-1.0', version: '>= 1.10'),
|
||||
dependency('libadwaita-1', version: '>= 1.6'),
|
||||
|
@ -21,42 +20,26 @@ audrey_deps = [
|
|||
dependency('mpv', version: '>= 2.3'),
|
||||
]
|
||||
|
||||
subdir('ui')
|
||||
audrey_sources += removeme
|
||||
|
||||
audrey_sources += gnome.compile_resources(
|
||||
'audrey-resources',
|
||||
'audrey.gresource.xml',
|
||||
c_name: 'audrey',
|
||||
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'],
|
||||
config_rs = configure_file(
|
||||
output: 'meson_config.rs',
|
||||
input: 'meson_config.rs.in',
|
||||
configuration: config_h,
|
||||
)
|
||||
|
||||
executable(
|
||||
audrey_c = static_library(
|
||||
'audrey',
|
||||
audrey_sources,
|
||||
dependencies: audrey_deps,
|
||||
include_directories: config_inc,
|
||||
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
1
src/meson_config.rs
Normal file
|
@ -0,0 +1 @@
|
|||
include!(concat!(env!("MESON_BUILD_ROOT"), "/src/meson_config.rs"));
|
1
src/meson_config.rs.in
Normal file
1
src/meson_config.rs.in
Normal file
|
@ -0,0 +1 @@
|
|||
pub static LOCALEDIR: &str = @LOCALEDIR@;
|
30
src/mpris.rs
Normal file
30
src/mpris.rs
Normal 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(),
|
||||
}
|
||||
}
|
217
src/mpris.vala
217
src/mpris.vala
|
@ -1,5 +1,5 @@
|
|||
[DBus (name = "org.mpris.MediaPlayer2")]
|
||||
class Mpris : Object {
|
||||
class Audrey.Mpris : Object {
|
||||
internal signal void on_raise ();
|
||||
internal signal void on_quit ();
|
||||
|
||||
|
@ -20,10 +20,19 @@ class Mpris : Object {
|
|||
public string desktop_entry { owned get { return "eu.callcc.audrey"; } }
|
||||
public string[] supported_uri_schemes { 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")]
|
||||
class MprisPlayer : Object {
|
||||
class Audrey.MprisPlayer : Object {
|
||||
internal signal void on_next ();
|
||||
internal signal void on_previous ();
|
||||
internal signal void on_pause ();
|
||||
|
@ -31,7 +40,7 @@ class MprisPlayer : Object {
|
|||
internal signal void on_stop ();
|
||||
internal signal void on_play ();
|
||||
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 previous () throws Error { this.on_previous (); }
|
||||
|
@ -40,26 +49,206 @@ class MprisPlayer : Object {
|
|||
public void stop () throws Error { this.on_stop (); }
|
||||
public void play () throws Error { this.on_play (); }
|
||||
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 signal void seeked (int64 position);
|
||||
|
||||
public string playback_status { owned get; internal set; default = "Stopped"; }
|
||||
public string loop_status { owned get; set; default = "None"; }
|
||||
public double rate { get; set; default = 1.0; }
|
||||
public bool shuffle { get; set; default = false; }
|
||||
public HashTable<string, Variant> metadata_map { owned get; default = new HashTable<string,Variant>(null, null); }
|
||||
public string loop_status { owned get; /*set;*/ default = "None"; }
|
||||
public double rate { get; /*set*/ default = 1.0; }
|
||||
public bool shuffle { get; /*set*/ default = false; }
|
||||
public HashTable<string, Variant> metadata { owned get; private set; default = new HashTable<string, Variant> (null, null); }
|
||||
public double volume { get; set; default = 1.0; }
|
||||
[CCode (notify = false)]
|
||||
public int64 position { get; default = 0; }
|
||||
public double minimum_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_previous { get; default = false; }
|
||||
public bool can_play { get; default = false; }
|
||||
public bool can_pause { get; default = false; }
|
||||
public bool can_seek { get; default = false; }
|
||||
public bool can_go_next { get; private set; default = false; }
|
||||
public bool can_go_previous { get; private set; default = false; }
|
||||
public bool can_play { get; private set; default = false; }
|
||||
public bool can_pause { get; private set; default = false; }
|
||||
public bool can_seek { get; private set; default = 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
27
src/mpris/player.rs
Normal 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
199
src/mpv.rs
Normal 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
6
src/mpv/ffi.rs
Normal 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
4
src/mpv/wrapper.h
Normal 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
77
src/playbin.rs
Normal 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) }
|
||||
}
|
||||
}
|
338
src/playbin.vala
338
src/playbin.vala
|
@ -1,15 +1,75 @@
|
|||
public enum PlaybinState {
|
||||
public enum Audrey.PlaybinState {
|
||||
STOPPED,
|
||||
PAUSED,
|
||||
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 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; }
|
||||
|
||||
private int _volume = 100;
|
||||
public int volume {
|
||||
get { return _volume; }
|
||||
set {
|
||||
|
@ -22,7 +82,6 @@ public class Playbin : GLib.Object {
|
|||
}
|
||||
}
|
||||
|
||||
public bool _mute = false;
|
||||
public bool mute {
|
||||
get { return _mute; }
|
||||
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 ();
|
||||
|
||||
// these are mostly synced with mpv
|
||||
public double position { 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) {
|
||||
for (uint i = 0; i < removed; i += 1) {
|
||||
assert (this.mpv.command ({
|
||||
"playlist-remove",
|
||||
position.to_string (),
|
||||
}) >= 0);
|
||||
}
|
||||
private async Mpv.Error mpv_command_async (string[] args) {
|
||||
CommandCallback cc = {};
|
||||
|
||||
for (uint i = 0; i < added; i += 1) {
|
||||
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);
|
||||
}
|
||||
this.mpv.command_async ((uint64) &cc, args);
|
||||
|
||||
if (this.play_queue_position == position && removed > 0) {
|
||||
if (this.play_queue_position < this.play_queue.get_n_items ()) {
|
||||
// edge case: new track plays, playlist-pos doesn't change, so now_playing never n gets triggered
|
||||
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));
|
||||
}
|
||||
}
|
||||
cc.callback = this.mpv_command_async.callback;
|
||||
yield;
|
||||
return cc.error;
|
||||
}
|
||||
|
||||
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.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 ("video", "no") >= 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 (1, "duration", Mpv.Format.DOUBLE) >= 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 = () => {
|
||||
Idle.add (() => {
|
||||
if (this.is_handling_event) {
|
||||
warning ("main thread mpv wakeup callback called twice");
|
||||
return false;
|
||||
}
|
||||
this.is_handling_event = true;
|
||||
|
||||
while (true) {
|
||||
var event = this.mpv.wait_event (0.0);
|
||||
if (event.event_id == Mpv.EventId.NONE) break;
|
||||
|
@ -109,25 +161,42 @@ public class Playbin : GLib.Object {
|
|||
case 1:
|
||||
assert (data.name == "duration");
|
||||
if (data.format == Mpv.Format.NONE) {
|
||||
this.duration = 0.0;
|
||||
// this.duration = 0.0; i think this prevents the fallback below from working
|
||||
} else {
|
||||
this.duration = data.parse_double ();
|
||||
}
|
||||
break;
|
||||
|
||||
case 2:
|
||||
// here as a sanity check
|
||||
// should always match our own play_queu_position/state
|
||||
assert (data.name == "playlist-pos");
|
||||
if (data.parse_int64 () < 0) {
|
||||
debug ("playlist-pos is null, sending stopped event");
|
||||
this.play_queue_position = this.play_queue.get_n_items ();
|
||||
this.state = PlaybinState.STOPPED;
|
||||
this.stopped ();
|
||||
int64 playlist_pos = data.parse_int64 ();
|
||||
if (playlist_pos < 0) {
|
||||
if (this.state != PlaybinState.STOPPED) {
|
||||
error ("mpv has no current playlist entry, but we think it's index %d", this.play_queue_position);
|
||||
}
|
||||
assert (this.play_queue_position < 0);
|
||||
} else {
|
||||
this.play_queue_position = (uint) data.parse_int64 ();
|
||||
debug (@"playlist-pos has been updated to $(this.play_queue_position)");
|
||||
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));
|
||||
if (this.state == PlaybinState.STOPPED) {
|
||||
error ("mpv is at playlist entry %d, but we're stopped", (int) playlist_pos);
|
||||
}
|
||||
if (this.play_queue_position != (int) playlist_pos) {
|
||||
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;
|
||||
|
||||
|
@ -139,14 +208,40 @@ public class Playbin : GLib.Object {
|
|||
|
||||
case Mpv.EventId.START_FILE:
|
||||
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;
|
||||
|
||||
case Mpv.EventId.END_FILE:
|
||||
debug ("END_FILE received");
|
||||
var data = event.parse_end_file ();
|
||||
debug (@"END_FILE received (reason: $(data.reason))");
|
||||
|
||||
if (data.error < 0) {
|
||||
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;
|
||||
|
||||
default:
|
||||
|
@ -155,14 +250,19 @@ public class Playbin : GLib.Object {
|
|||
}
|
||||
}
|
||||
|
||||
this.is_handling_event = false;
|
||||
return false;
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
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;
|
||||
assert (this.mpv.command ({"seek", position.to_string (), "absolute"}) >= 0);
|
||||
}
|
||||
}
|
||||
|
||||
// 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 ())
|
||||
{
|
||||
assert (this.mpv.command ({"playlist-play-index", position.to_string ()}) >= 0);
|
||||
this.play_queue_position = (int) position;
|
||||
this.state = PlaybinState.PLAYING;
|
||||
this.play (); // make sure mpv actually starts playing the track
|
||||
}
|
||||
|
||||
public void pause () {
|
||||
|
@ -185,7 +287,12 @@ public class Playbin : GLib.Object {
|
|||
}
|
||||
|
||||
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;
|
||||
debug ("setting state to playing");
|
||||
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())");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void next_track () {
|
||||
assert (this.state != PlaybinState.STOPPED);
|
||||
this.state = PlaybinState.PLAYING;
|
||||
public void go_to_next_track ()
|
||||
requires (this.state != PlaybinState.STOPPED)
|
||||
{
|
||||
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 prev_track () {
|
||||
assert (this.state != PlaybinState.STOPPED);
|
||||
this.state = PlaybinState.PLAYING;
|
||||
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);
|
||||
} 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
68
src/playbin/song.rs
Normal 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
46
src/playbin/state.rs
Normal 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
5
src/subsonic.rs
Normal file
|
@ -0,0 +1,5 @@
|
|||
mod client;
|
||||
pub use client::Client;
|
||||
|
||||
mod song;
|
||||
pub use song::Song;
|
|
@ -1,11 +1,11 @@
|
|||
public errordomain Subsonic.Error {
|
||||
public errordomain Audrey.Subsonic.Error {
|
||||
BAD_AUTHN,
|
||||
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 id;
|
||||
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 name;
|
||||
|
||||
|
@ -53,15 +53,18 @@ public class Subsonic.Album : Object {
|
|||
}
|
||||
}
|
||||
|
||||
public class Subsonic.Song : Object {
|
||||
public string id { get; private set; }
|
||||
public string title { get; private set; }
|
||||
public string album { get; private set; }
|
||||
public string artist { get; private set; }
|
||||
public int64 track { get; private set; }
|
||||
public int64 year { get; private set; }
|
||||
public DateTime? starred { get; private set; }
|
||||
public int64 duration { get; private set; }
|
||||
public struct Audrey.Subsonic.Song {
|
||||
public string id;
|
||||
public string title;
|
||||
public string album;
|
||||
public string artist;
|
||||
public int64 track;
|
||||
public int64 year;
|
||||
public DateTime? starred; // TODO
|
||||
public int64 duration;
|
||||
public int64 play_count;
|
||||
public string? genre;
|
||||
public string cover_art;
|
||||
|
||||
public Song (Json.Reader reader) {
|
||||
reader.read_member ("id");
|
||||
|
@ -91,53 +94,22 @@ public class Subsonic.Song : Object {
|
|||
reader.read_member ("duration");
|
||||
this.duration = reader.get_int_value ();
|
||||
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 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;
|
||||
|
||||
public class Audrey.Subsonic.Client : Object {
|
||||
private Soup.Session session;
|
||||
private string url;
|
||||
private string parameters;
|
||||
|
@ -148,10 +120,6 @@ public class Subsonic.Client : Object {
|
|||
|
||||
this.session = new Soup.Session ();
|
||||
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 {
|
||||
|
@ -222,8 +190,11 @@ public class Subsonic.Client : Object {
|
|||
|
||||
for (int i = 0; i < reader.count_elements (); i += 1) {
|
||||
reader.read_element (i);
|
||||
callback (new Song (reader));
|
||||
callback (Song (reader));
|
||||
reader.end_element ();
|
||||
|
||||
Idle.add (this.get_random_songs.callback);
|
||||
yield;
|
||||
}
|
||||
|
||||
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)";
|
||||
}
|
||||
|
||||
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)";
|
||||
} 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 {
|
||||
var msg = new Soup.Message("GET", this.cover_art_uri (id));
|
||||
public async Gdk.Pixbuf cover_art (
|
||||
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);
|
||||
|
||||
var stream = yield this.session.send_async (msg, Priority.DEFAULT, cancellable);
|
||||
assert (msg.get_status () == Soup.Status.OK);
|
||||
var stream = yield this.session.send_async (msg, priority, cancellable);
|
||||
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);
|
||||
}
|
||||
|
||||
~Client () {
|
||||
debug ("destroying subsonic client");
|
||||
}
|
||||
}
|
||||
|
|
27
src/subsonic/client.rs
Normal file
27
src/subsonic/client.rs
Normal 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
39
src/subsonic/song.rs
Normal 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
5
src/ui.rs
Normal file
|
@ -0,0 +1,5 @@
|
|||
mod window;
|
||||
pub use window::Window;
|
||||
|
||||
mod playbar;
|
||||
pub use playbar::Playbar;
|
|
@ -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@',
|
||||
],
|
||||
)
|
|
@ -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
2
src/ui/play_queue.rs
Normal file
|
@ -0,0 +1,2 @@
|
|||
mod song;
|
||||
pub use song::Song;
|
|
@ -1,22 +1,28 @@
|
|||
[GtkTemplate (ui = "/eu/callcc/audrey/ui/play_queue_song.ui")]
|
||||
class Ui.PlayQueueSong : Gtk.ListBoxRow {
|
||||
// song widget+drag behavior taken from gnome music
|
||||
|
||||
[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 {
|
||||
get { return _current; }
|
||||
set {
|
||||
this._current = value;
|
||||
if (value) {
|
||||
this.play_icon_name = "media-playback-start";
|
||||
this.add_css_class ("playing");
|
||||
} else {
|
||||
this.play_icon_name = "";
|
||||
this.remove_css_class ("playing");
|
||||
}
|
||||
}
|
||||
}
|
||||
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 Playbin playbin;
|
||||
private weak Playbin playbin;
|
||||
public PlayQueueSong (Playbin playbin) {
|
||||
this.playbin = playbin;
|
||||
|
||||
|
@ -24,7 +30,7 @@ class Ui.PlayQueueSong : Gtk.ListBoxRow {
|
|||
|
||||
var remove = new SimpleAction ("remove", null);
|
||||
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);
|
||||
|
||||
|
@ -32,13 +38,15 @@ class Ui.PlayQueueSong : Gtk.ListBoxRow {
|
|||
}
|
||||
|
||||
private ulong connection;
|
||||
public void bind (uint position, Subsonic.Song song) {
|
||||
public void bind (uint position, PlaybinSong song) {
|
||||
this.displayed_position = position+1;
|
||||
this.song = song;
|
||||
this.current = this.playbin.play_queue_position == position;
|
||||
this.connection = this.playbin.notify["play-queue-position"].connect (() => {
|
||||
this.current = this.playbin.play_queue_position == position;
|
||||
});
|
||||
|
||||
song.need_cover_art ();
|
||||
}
|
||||
|
||||
public void unbind () {
|
||||
|
@ -53,20 +61,59 @@ class Ui.PlayQueueSong : Gtk.ListBoxRow {
|
|||
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) {
|
||||
if (this.draggable) {
|
||||
this.drag_x = x;
|
||||
this.drag_y = y;
|
||||
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) {
|
||||
this.drag_widget = null;
|
||||
this.drag_x = 0.0;
|
||||
this.drag_y = 0.0;
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
[GtkTemplate (ui = "/eu/callcc/audrey/ui/play_queue.ui")]
|
||||
public class Ui.PlayQueue : Adw.NavigationPage {
|
||||
private Playbin _playbin;
|
||||
[GtkTemplate (ui = "/eu/callcc/audrey/play_queue.ui")]
|
||||
public class Audrey.Ui.PlayQueue : Adw.Bin {
|
||||
private weak Playbin _playbin;
|
||||
public Playbin playbin {
|
||||
get { return _playbin; }
|
||||
set {
|
||||
|
@ -75,17 +122,14 @@ public class Ui.PlayQueue : Adw.NavigationPage {
|
|||
|
||||
_playbin.play_queue.items_changed.connect (this.on_store_items_changed);
|
||||
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; }
|
||||
|
||||
[GtkCallback] private void on_clear () {
|
||||
this.playbin.play_queue.remove_all ();
|
||||
}
|
||||
/*[GtkCallback] private void on_clear () {
|
||||
this.playbin.clear ();
|
||||
}*/
|
||||
|
||||
private void on_store_items_changed (GLib.ListModel store, uint position, uint removed, uint added) {
|
||||
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 child = new PlayQueueSong (this.playbin);
|
||||
|
||||
child.draggable = true;
|
||||
child.show_position = true;
|
||||
child.show_artist = true;
|
||||
child.show_cover = true;
|
||||
|
||||
item.child = child;
|
||||
}
|
||||
|
||||
|
@ -102,7 +151,7 @@ public class Ui.PlayQueue : Adw.NavigationPage {
|
|||
var item = object as Gtk.ListItem;
|
||||
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) {
|
||||
|
@ -115,4 +164,12 @@ public class Ui.PlayQueue : Adw.NavigationPage {
|
|||
[GtkCallback] private void on_row_activated (uint 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
65
src/ui/play_queue/song.rs
Normal 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;
|
||||
}
|
|
@ -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
1
src/ui/playbar.h
Normal file
|
@ -0,0 +1 @@
|
|||
typedef struct _AudreyUiPlaybar AudreyUiPlaybar;
|
203
src/ui/playbar.rs
Normal file
203
src/ui/playbar.rs
Normal 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: >k::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: >k::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
100
src/ui/playbar.vapi
Normal 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");
|
||||
}
|
||||
*/
|
||||
}
|
|
@ -16,8 +16,8 @@ static void salt_password (string password, out string token, out string salt) {
|
|||
salt = (string) salt_chars;
|
||||
}
|
||||
|
||||
[GtkTemplate (ui = "/eu/callcc/audrey/ui/setup.ui")]
|
||||
public class Ui.Setup : Adw.PreferencesDialog {
|
||||
[GtkTemplate (ui = "/eu/callcc/audrey/setup.ui")]
|
||||
public class Audrey.Ui.Setup : Adw.PreferencesDialog {
|
||||
public string status { get; private set; default = _("Not connected"); }
|
||||
|
||||
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_schema,
|
||||
new HashTable<string, string> (null, null),
|
||||
Secret.SearchFlags.NONE,
|
||||
Secret.SearchFlags.UNLOCK,
|
||||
null,
|
||||
(obj, res) => {
|
||||
try {
|
||||
|
@ -128,4 +128,8 @@ public class Ui.Setup : Adw.PreferencesDialog {
|
|||
this.authn_can_edit = true;
|
||||
}, "server-url", this.server_url, "username", this.username);
|
||||
}
|
||||
|
||||
~Setup () {
|
||||
debug ("destroying setup dialog");
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
39
src/ui/window.rs
Normal 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)) }
|
||||
}
|
||||
}
|
|
@ -1,11 +1,8 @@
|
|||
[GtkTemplate (ui = "/eu/callcc/audrey/ui/window.ui")]
|
||||
class Ui.Window : Adw.ApplicationWindow {
|
||||
[GtkChild] private unowned Gtk.ListBox sidebar;
|
||||
[GtkChild] private unowned Gtk.ListBoxRow sidebar_play_queue;
|
||||
[GtkChild] private unowned Gtk.Stack stack;
|
||||
|
||||
[GtkChild] public unowned Ui.PlayQueue play_queue;
|
||||
[GtkChild] public unowned Adw.ButtonRow shuffle_all_tracks;
|
||||
[GtkTemplate (ui = "/eu/callcc/audrey/window.ui")]
|
||||
class Audrey.Ui.Window : Adw.ApplicationWindow {
|
||||
[GtkChild] public unowned PlayQueue play_queue;
|
||||
[GtkChild] public unowned Playbar playbar;
|
||||
//[GtkChild] public unowned Adw.ButtonRow shuffle_all_tracks;
|
||||
|
||||
private Setup setup;
|
||||
|
||||
|
@ -20,11 +17,11 @@ class Ui.Window : Adw.ApplicationWindow {
|
|||
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;
|
||||
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 (); }
|
||||
|
||||
|
@ -32,42 +29,23 @@ class Ui.Window : Adw.ApplicationWindow {
|
|||
Object (application: app);
|
||||
}
|
||||
|
||||
construct {
|
||||
// TODO: mpris
|
||||
// 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"); });
|
||||
private Mpris mpris;
|
||||
private MprisPlayer mpris_player;
|
||||
|
||||
this.setup = new Setup ();
|
||||
|
||||
this.setup.connected.connect ((api) => {
|
||||
this.api = api;
|
||||
this.playbin.api = api;
|
||||
|
||||
this.playbin.now_playing.connect ((playbin, now, next) => {
|
||||
this.song = now;
|
||||
api.scrobble.begin (this.song.id);
|
||||
private void now_playing (PlaybinSong song) {
|
||||
this.song = song;
|
||||
// api.scrobble.begin (this.song.id); TODO
|
||||
|
||||
if (this.cancel_loading_art != null) {
|
||||
this.cancel_loading_art.cancel ();
|
||||
}
|
||||
this.cancel_loading_art = new GLib.Cancellable ();
|
||||
|
||||
this.playing_cover_art = Gdk.Paintable.empty (1, 1);
|
||||
if (this.song != null) {
|
||||
this.playing_cover_art = null; // TODO: preload next art somehow
|
||||
this.cover_art_loading = true;
|
||||
|
||||
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 {
|
||||
this.playing_cover_art = Gdk.Texture.for_pixbuf (this.api.cover_art.end (res));
|
||||
this.cover_art_loading = false;
|
||||
|
@ -79,114 +57,80 @@ class Ui.Window : Adw.ApplicationWindow {
|
|||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
this.playbin.stopped.connect (() => {
|
||||
this.playing_cover_art = Gdk.Paintable.empty (1, 1);
|
||||
this.song = null;
|
||||
});
|
||||
|
||||
this.shuffle_all_tracks.sensitive = true;
|
||||
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) => {
|
||||
construct {
|
||||
Bus.own_name (
|
||||
BusType.SESSION,
|
||||
"org.mpris.MediaPlayer2.audrey",
|
||||
BusNameOwnerFlags.NONE,
|
||||
(conn) => {
|
||||
try {
|
||||
api.get_random_songs.end (res);
|
||||
} catch (Error e) {
|
||||
error ("could not get random songs: %s", e.message);
|
||||
}
|
||||
this.shuffle_all_tracks.sensitive = true;
|
||||
this.mpris = new Mpris (this);
|
||||
this.mpris_player = new MprisPlayer (conn, this.playbin);
|
||||
|
||||
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.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) {
|
||||
if (row == this.sidebar_play_queue) {
|
||||
this.stack.set_visible_child_name("play_queue");
|
||||
}
|
||||
}
|
||||
|
||||
[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 ();
|
||||
this.playbin.stopped.connect (() => {
|
||||
this.playing_cover_art = null;
|
||||
this.song = null;
|
||||
});
|
||||
}
|
||||
|
||||
[GtkCallback] private void show_setup_dialog () {
|
||||
this.setup.present (this);
|
||||
}
|
||||
|
||||
[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);
|
||||
public bool can_click_shuffle_all { get; private set; default = false; }
|
||||
|
||||
[GtkCallback] private void shuffle_all () {
|
||||
this.can_click_shuffle_all = false;
|
||||
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 () {
|
||||
// 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 bool show_playbar_cover_art (string? stack_child) {
|
||||
return stack_child != "play-queue";
|
||||
}
|
||||
|
||||
[GtkCallback] private string song_title (Subsonic.Song? song) {
|
||||
return song == null ? "" : song.title;
|
||||
public override bool close_request () {
|
||||
// stop playback on close
|
||||
this.playbin.stop ();
|
||||
return false;
|
||||
}
|
||||
|
||||
[GtkCallback] private string song_artist (Subsonic.Song? song) {
|
||||
return song == null ? "" : song.artist;
|
||||
}
|
||||
|
||||
[GtkCallback] private string song_album (Subsonic.Song? song) {
|
||||
return song == null ? "" : song.album;
|
||||
~Window () {
|
||||
debug ("destroying main window");
|
||||
}
|
||||
}
|
||||
|
|
|
@ -31,7 +31,7 @@ namespace Mpv {
|
|||
|
||||
public delegate void WakeupCallback ();
|
||||
|
||||
[CCode (cname = "mpv_handle", free_function = "mpv_destroy")]
|
||||
[CCode (cname = "mpv_handle", free_function = "mpv_terminate_destroy")]
|
||||
[Compact]
|
||||
public class Handle {
|
||||
[CCode (cname = "mpv_create")]
|
||||
|
@ -65,6 +65,9 @@ namespace Mpv {
|
|||
[CCode (cname = "mpv_command")]
|
||||
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")]
|
||||
public Error observe_property (uint64 reply_userdata, string name, Format format);
|
||||
}
|
||||
|
@ -101,7 +104,10 @@ namespace Mpv {
|
|||
PLAYBACK_RESTART,
|
||||
PROPERTY_CHANGE,
|
||||
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)]
|
||||
|
@ -143,6 +149,12 @@ namespace Mpv {
|
|||
{
|
||||
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)]
|
||||
|
|
Loading…
Reference in a new issue