Compare commits

..

No commits in common. "trunk" and "int" have entirely different histories.
trunk ... int

48 changed files with 810 additions and 3133 deletions

1
.gitignore vendored
View file

@ -1 +0,0 @@
target/

1034
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -1,16 +0,0 @@
[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"

View file

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

View file

@ -8,10 +8,10 @@ project(
i18n = import('i18n')
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,48 +38,13 @@ add_project_arguments(
language: 'c',
)
subdir('data')
subdir('po')
subdir('resources')
subdir('src')
subdir('po')
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: ' ',
gnome.post_install(
glib_compile_schemas: true,
gtk_update_icon_cache: true,
update_desktop_database: true,
)
custom_target(
'cargo-build',
build_by_default: true,
build_always_stale: true,
depends: [audrey_c, blueprints],
input: config_rs,
console: true,
# means nothing (always stale and uncopied), we can't cp since env: drops the /bin/sh wrapper
output: meson.project_name(),
command: [
cargo,
'build',
rust_args,
'--manifest-path',
meson.project_source_root() / 'Cargo.toml',
'--target-dir',
meson.project_build_root() / 'cargo-target',
],
env: cargo_env,
)
run_target('clippy', command: [cargo, 'clippy'], env: cargo_env)
run_target('cargo-doc', command: [cargo, 'doc'], env: cargo_env)

View file

@ -1,12 +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">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>

View file

@ -1,31 +0,0 @@
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,
)

View file

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

View file

@ -1,132 +0,0 @@
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")
}

View file

@ -1,172 +0,0 @@
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 ();
}
[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 ();
}
Button {
icon-name: "media-seek-backward";
valign: center;
sensitive: bind $playbin_active (template.playbin as <$AudreyPlaybin>.state as <$AudreyPlaybinState>) as <bool>;
clicked => $seek_backward ();
}
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 ();
}
Button {
icon-name: "media-seek-forward";
valign: center;
sensitive: bind $playbin_active (template.playbin as <$AudreyPlaybin>.state as <$AudreyPlaybinState>) as <bool>;
clicked => $seek_forward ();
}
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 ();
}
}
}
[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 ();
}
Scale {
name: "volume-scale";
orientation: horizontal;
width-request: 130;
adjustment: Adjustment {
lower: 0;
value: bind template.volume bidirectional;
upper: 100;
};
}
}
};
}

View file

@ -1,136 +0,0 @@
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>;
}
}
}

View file

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

View file

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

11
src/audrey.gresource.xml Normal file
View file

@ -0,0 +1,11 @@
<?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>

View file

@ -1,39 +0,0 @@
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");
gtk::disable_setlocale();
bindtextdomain("audrey", meson_config::LOCALEDIR).expect("failed to bind text domain");
bind_textdomain_codeset("audrey", "UTF-8").expect("failed to bind textdomaincodeset");
textdomain("audrey").expect("unable to switch to text domain");
setlocale(LocaleCategory::LcAll, "");
setlocale(LocaleCategory::LcNumeric, "C.UTF-8");
let app = Application::new();
app.run()
}

View file

@ -1,16 +1,17 @@
audrey_sources = [
'application.vala',
'globalconf.vala',
'main.vala',
'mpris.vala',
'playbin.vala',
'subsonic.vala',
'ui/play_queue.vala',
'ui/playbar.vala',
'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'),
@ -20,26 +21,42 @@ audrey_deps = [
dependency('mpv', version: '>= 2.3'),
]
audrey_sources += removeme
subdir('ui')
config_rs = configure_file(
output: 'meson_config.rs',
input: 'meson_config.rs.in',
configuration: config_h,
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'],
)
audrey_c = static_library(
executable(
'audrey',
audrey_sources,
dependencies: audrey_deps,
include_directories: config_inc,
install: true,
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',
vala_args: ['--vapidir', meson.current_source_dir() / 'vapi'],
)

View file

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

View file

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

View file

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

View file

@ -1,5 +1,5 @@
[DBus (name = "org.mpris.MediaPlayer2")]
class Audrey.Mpris : Object {
class Mpris : Object {
internal signal void on_raise ();
internal signal void on_quit ();
@ -20,19 +20,10 @@ class Audrey.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 Audrey.MprisPlayer : Object {
class MprisPlayer : Object {
internal signal void on_next ();
internal signal void on_previous ();
internal signal void on_pause ();
@ -40,7 +31,7 @@ class Audrey.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 (ObjectPath track_id, int64 position);
internal signal void on_set_position (string track_id, int64 position);
public void next () throws Error { this.on_next (); }
public void previous () throws Error { this.on_previous (); }
@ -49,206 +40,26 @@ class Audrey.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 (ObjectPath track_id, int64 position) throws Error { this.on_set_position (track_id, position); }
public void set_position (string 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 { owned get; private set; 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_map { owned get; 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; 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; }
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; }
[CCode (notify = 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");
}
public bool can_control { get { return false; } }
}

View file

@ -1,27 +0,0 @@
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(),
}
}

View file

@ -1,199 +0,0 @@
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!()
}
}

View file

@ -1,6 +0,0 @@
#![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"));

View file

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

View file

@ -1,30 +0,0 @@
mod song;
pub use song::Song;
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;
}
}
use gtk::glib;
glib::wrapper! {
pub struct Playbin(Object<ffi::AudreyPlaybin, ffi::AudreyPlaybinClass>);
match fn {
type_ => || ffi::audrey_playbin_get_type(),
}
}

View file

@ -1,75 +1,15 @@
public enum Audrey.PlaybinState {
public enum PlaybinState {
STOPPED,
PAUSED,
PLAYING,
}
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 {
public class 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 {
@ -82,6 +22,7 @@ public class Audrey.Playbin : GLib.Object {
}
}
public bool _mute = false;
public bool mute {
get { return _mute; }
set {
@ -94,36 +35,50 @@ public class Audrey.Playbin : GLib.Object {
}
}
// invariant: negative iff stopped, otherwise < play queue length
public int play_queue_position { get; private set; default = -1; }
public uint play_queue_position { get; private set; }
// signalled when a new track is current
public signal void new_track ();
// signalled when the last track is over
public signal void now_playing (Subsonic.Song now, Subsonic.Song? next);
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 weak Subsonic.Client api { get; set; default = null; }
public Subsonic.Client api { get; set; default = null; }
public ListModel play_queue { get { return this._play_queue; } }
public uint play_queue_length { get; private set; default = 0; }
public ListStore play_queue { get; private set; }
private async Mpv.Error mpv_command_async (string[] args) {
CommandCallback cc = {};
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);
}
this.mpv.command_async ((uint64) &cc, args);
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);
}
cc.callback = this.mpv_command_async.callback;
yield;
return cc.error;
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));
}
}
}
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);
@ -131,16 +86,9 @@ public class Audrey.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;
@ -161,42 +109,25 @@ public class Audrey.Playbin : GLib.Object {
case 1:
assert (data.name == "duration");
if (data.format == Mpv.Format.NONE) {
// this.duration = 0.0; i think this prevents the fallback below from working
this.duration = 0.0;
} 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");
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);
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 ();
} else {
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");
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));
}
break;
@ -208,40 +139,14 @@ public class Audrey.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:
@ -250,19 +155,14 @@ public class Audrey.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;
}
this.position = position;
assert (this.mpv.command ({"seek", position.to_string (), "absolute"}) >= 0);
}
// manually changes which track in the play queue to play
@ -270,9 +170,7 @@ public class Audrey.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 () {
@ -287,152 +185,24 @@ public class Audrey.Playbin : GLib.Object {
}
public void play () {
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);
if (ret != 0) {
debug (@"failed to set state to playing ($(ret)): $(ret.to_string())");
}
assert (this.state != PlaybinState.STOPPED);
this.state = PlaybinState.PLAYING;
debug ("setting state to playing");
var ret = this.mpv.set_property_flag("pause", false);
if (ret != 0) {
debug (@"failed to set state to playing ($(ret)): $(ret.to_string())");
}
}
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 next_track () {
assert (this.state != PlaybinState.STOPPED);
this.state = PlaybinState.PLAYING;
assert (this.mpv.command ({"playlist-next-playlist"}) >= 0);
}
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");
public void prev_track () {
assert (this.state != PlaybinState.STOPPED);
this.state = PlaybinState.PLAYING;
assert (this.mpv.command ({"playlist-prev-playlist"}) >= 0);
}
}

View file

@ -1,27 +0,0 @@
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;
}
}
use gtk::glib;
glib::wrapper! {
pub struct Song(Object<ffi::AudreyPlaybinSong, ffi::AudreyPlaybinSongClass>);
match fn {
type_ => || ffi::audrey_playbin_song_get_type(),
}
}

View file

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

View file

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

View file

@ -1,11 +1,11 @@
public errordomain Audrey.Subsonic.Error {
public errordomain Subsonic.Error {
BAD_AUTHN,
ERROR,
}
public delegate void Audrey.Subsonic.SongCallback (Song song);
public delegate void Subsonic.SongCallback (Song song);
public class Audrey.Subsonic.Artist : Object {
public class Subsonic.Artist : Object {
public string index;
public string id;
public string name { get; private set; }
@ -38,7 +38,7 @@ public class Audrey.Subsonic.Artist : Object {
}
}
public class Audrey.Subsonic.Album : Object {
public class Subsonic.Album : Object {
public string id;
public string name;
@ -53,18 +53,15 @@ public class Audrey.Subsonic.Album : Object {
}
}
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 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 Song (Json.Reader reader) {
reader.read_member ("id");
@ -94,22 +91,53 @@ public struct Audrey.Subsonic.Song {
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 class Audrey.Subsonic.Client : Object {
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;
private Soup.Session session;
private string url;
private string parameters;
@ -120,6 +148,10 @@ public class Audrey.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 {
@ -190,11 +222,8 @@ public class Audrey.Subsonic.Client : Object {
for (int i = 0; i < reader.count_elements (); i += 1) {
reader.read_element (i);
callback (Song (reader));
callback (new Song (reader));
reader.end_element ();
Idle.add (this.get_random_songs.callback);
yield;
}
assert (reader.get_error () == null);
@ -204,33 +233,16 @@ public class Audrey.Subsonic.Client : Object {
return @"$(this.url)/rest/stream?id=$(Uri.escape_string(id))&$(this.parameters)";
}
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 string cover_art_uri (string id) {
return @"$(this.url)/rest/getCoverArt?id=$(Uri.escape_string(id))&$(this.parameters)";
}
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));
public async Gdk.Pixbuf cover_art (string id, Cancellable cancellable) throws GLib.Error {
var msg = new Soup.Message("GET", this.cover_art_uri (id));
assert (msg != null);
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 ()));
}
var stream = yield this.session.send_async (msg, Priority.DEFAULT, cancellable);
assert (msg.get_status () == Soup.Status.OK);
return yield new Gdk.Pixbuf.from_stream_async (stream, cancellable);
}
~Client () {
debug ("destroying subsonic client");
}
}

View file

@ -1,27 +0,0 @@
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(),
}
}

View file

@ -1,38 +0,0 @@
mod ffi {
use std::ffi::*;
use gtk::glib;
#[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(),
}
}

View file

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

12
src/ui/meson.build Normal file
View file

@ -0,0 +1,12 @@
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@',
],
)

36
src/ui/play_queue.blp Normal file
View file

@ -0,0 +1,36 @@
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 ();
};
}
}
}
}

View file

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

View file

@ -1,28 +1,22 @@
// 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;
[GtkTemplate (ui = "/eu/callcc/audrey/ui/play_queue_song.ui")]
class Ui.PlayQueueSong : Gtk.ListBoxRow {
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 PlaybinSong song { get; set; }
public Subsonic.Song song { get; set; }
private weak Playbin playbin;
public string play_icon_name { get; set; default = ""; }
private Playbin playbin;
public PlayQueueSong (Playbin playbin) {
this.playbin = playbin;
@ -30,7 +24,7 @@ class Audrey.Ui.PlayQueueSong : Gtk.Box {
var remove = new SimpleAction ("remove", null);
remove.activate.connect (() => {
this.playbin.remove_track (this.displayed_position-1);
this.playbin.play_queue.remove (this.displayed_position-1);
});
action_group.add_action (remove);
@ -38,15 +32,13 @@ class Audrey.Ui.PlayQueueSong : Gtk.Box {
}
private ulong connection;
public void bind (uint position, PlaybinSong song) {
public void bind (uint position, Subsonic.Song 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 () {
@ -61,59 +53,20 @@ class Audrey.Ui.PlayQueueSong : Gtk.Box {
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);
return new Gdk.ContentProvider.for_value (this);
}
[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;
debug ("dropped %u on %u", source.displayed_position, this.displayed_position);
this.playbin.move_track (source.displayed_position-1, this.displayed_position-1);
print ("dropped %u on %u", source.displayed_position, this.displayed_position);
return false;
}
}
[GtkTemplate (ui = "/eu/callcc/audrey/play_queue.ui")]
public class Audrey.Ui.PlayQueue : Adw.Bin {
private weak Playbin _playbin;
[GtkTemplate (ui = "/eu/callcc/audrey/ui/play_queue.ui")]
public class Ui.PlayQueue : Adw.NavigationPage {
private Playbin _playbin;
public Playbin playbin {
get { return _playbin; }
set {
@ -122,14 +75,17 @@ public class Audrey.Ui.PlayQueue : Adw.Bin {
_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.clear ();
}*/
[GtkCallback] private void on_clear () {
this.playbin.play_queue.remove_all ();
}
private void on_store_items_changed (GLib.ListModel store, uint position, uint removed, uint added) {
this.can_clear_all = store.get_n_items () > 0;
@ -139,11 +95,6 @@ public class Audrey.Ui.PlayQueue : Adw.Bin {
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;
}
@ -151,7 +102,7 @@ public class Audrey.Ui.PlayQueue : Adw.Bin {
var item = object as Gtk.ListItem;
var child = item.child as PlayQueueSong;
child.bind (item.position, item.item as PlaybinSong);
child.bind (item.position, item.item as Subsonic.Song);
}
[GtkCallback] private void on_song_list_unbind (Gtk.SignalListItemFactory factory, Object object) {
@ -164,12 +115,4 @@ public class Audrey.Ui.PlayQueue : Adw.Bin {
[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");
}
}

View file

@ -1,65 +0,0 @@
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;
}

145
src/ui/play_queue_song.blp Normal file
View file

@ -0,0 +1,145 @@
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")
}

View file

@ -1,29 +0,0 @@
mod ffi {
use gtk::glib;
#[repr(C)]
pub struct AudreyUiPlaybar {
parent_instance: adw::ffi::AdwBin,
}
#[repr(C)]
pub struct AudreyUiPlaybarClass {
parent_class: adw::ffi::AdwBinClass,
}
extern "C" {
pub fn audrey_ui_playbar_get_type() -> glib::ffi::GType;
}
}
use gtk::glib;
glib::wrapper! {
pub struct Playbar(Object<ffi::AudreyUiPlaybar, ffi::AudreyUiPlaybarClass>)
@extends adw::Bin, gtk::Widget,
@implements gtk::Accessible, gtk::Buildable, gtk::ConstraintTarget;
match fn {
type_ => || ffi::audrey_ui_playbar_get_type(),
}
}

View file

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

View file

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

View file

@ -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/setup.ui")]
public class Audrey.Ui.Setup : Adw.PreferencesDialog {
[GtkTemplate (ui = "/eu/callcc/audrey/ui/setup.ui")]
public class 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 Audrey.Ui.Setup : Adw.PreferencesDialog {
Secret.password_searchv.begin (
secret_schema,
new HashTable<string, string> (null, null),
Secret.SearchFlags.UNLOCK,
Secret.SearchFlags.NONE,
null,
(obj, res) => {
try {
@ -128,8 +128,4 @@ public class Audrey.Ui.Setup : Adw.PreferencesDialog {
this.authn_can_edit = true;
}, "server-url", this.server_url, "username", this.username);
}
~Setup () {
debug ("destroying setup dialog");
}
}

250
src/ui/window.blp Normal file
View file

@ -0,0 +1,250 @@
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;
};
}
}
}
};
}

View file

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

View file

@ -1,8 +1,11 @@
[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;
[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;
private Setup setup;
@ -17,11 +20,11 @@ class Audrey.Ui.Window : Adw.ApplicationWindow {
set { this.playbin.mute = value; }
}
public PlaybinSong? song { get; private set; }
public Gdk.Paintable? playing_cover_art { get; set; default = null; }
public Subsonic.Song? song { get; private set; }
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 (); }
@ -29,108 +32,161 @@ class Audrey.Ui.Window : Adw.ApplicationWindow {
Object (application: app);
}
private Mpris mpris;
private MprisPlayer mpris_player;
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 = null; // TODO: preload next art somehow
this.cover_art_loading = true;
string song_id = this.song.id;
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;
} catch (Error e) {
if (!(e is IOError.CANCELLED)) {
warning ("could not load cover for %s: %s", song_id, e.message);
this.cover_art_loading = false;
}
}
});
}
construct {
Bus.own_name (
BusType.SESSION,
"org.mpris.MediaPlayer2.audrey",
BusNameOwnerFlags.NONE,
(conn) => {
try {
this.mpris = new Mpris (this);
this.mpris_player = new MprisPlayer (conn, this.playbin);
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"); });
// 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"); });
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.playbin.now_playing.connect ((playbin, now, next) => {
this.song = now;
api.scrobble.begin (this.song.id);
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.cover_art_loading = true;
string song_id = this.song.id;
this.api.cover_art.begin (song_id, 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;
} catch (Error e) {
if (!(e is IOError.CANCELLED)) {
warning ("could not load cover for %s: %s", song_id, e.message);
this.cover_art_loading = false;
}
}
});
}
});
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) => {
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.playbin.select_track (0);
});
});
});
this.setup.load ();
this.playbin.new_track.connect (() => {
this.now_playing (this.playbin.play_queue.get_item (this.playbin.play_queue_position) as PlaybinSong);
});
this.sidebar.select_row (this.sidebar.get_row_at_index (0));
}
this.playbin.stopped.connect (() => {
this.playing_cover_art = null;
this.song = null;
});
[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 ();
}
[GtkCallback] private void show_setup_dialog () {
this.setup.present (this);
}
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_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 bool show_playbar_cover_art (string? stack_child) {
return stack_child != "play-queue";
[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);
}
public override bool close_request () {
// stop playback on close
this.playbin.stop ();
return false;
[GtkCallback] private string song_title (Subsonic.Song? song) {
return song == null ? "" : song.title;
}
~Window () {
debug ("destroying main window");
[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;
}
}

View file

@ -31,7 +31,7 @@ namespace Mpv {
public delegate void WakeupCallback ();
[CCode (cname = "mpv_handle", free_function = "mpv_terminate_destroy")]
[CCode (cname = "mpv_handle", free_function = "mpv_destroy")]
[Compact]
public class Handle {
[CCode (cname = "mpv_create")]
@ -65,9 +65,6 @@ 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);
}
@ -104,10 +101,7 @@ namespace Mpv {
PLAYBACK_RESTART,
PROPERTY_CHANGE,
QUEUE_OVERFLOW,
HOOK;
[CCode (cname = "mpv_event_name")]
public unowned string to_string ();
HOOK,
}
[CCode (cname = "mpv_event", destroy_function = "", has_type_id = false, has_copy_function = false)]
@ -149,12 +143,6 @@ 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)]