App-DrivePlayer
view release on metacpan or search on metacpan
lib/App/DrivePlayer/GUI.pm view on Meta::CPAN
$menu->append($item);
return $item;
}
sub _build_toolbar {
my ($self) = @_;
my $tb = Gtk3::Toolbar->new();
$tb->set_style('both-horiz');
my $scan_btn = Gtk3::ToolButton->new(
Gtk3::Image->new_from_icon_name('view-refresh', 'small-toolbar'),
'Sync'
);
$scan_btn->signal_connect(clicked => sub { $self->_sync_all() });
$tb->insert($scan_btn, -1);
my $add_btn = Gtk3::ToolButton->new(
Gtk3::Image->new_from_icon_name('folder-new', 'small-toolbar'),
'Add Folder'
);
$add_btn->signal_connect(clicked => sub { $self->_add_folder_dialog() });
$tb->insert($add_btn, -1);
$tb->insert(Gtk3::SeparatorToolItem->new(), -1);
my $settings_btn = Gtk3::ToolButton->new(
Gtk3::Image->new_from_icon_name('preferences-system', 'small-toolbar'),
'Settings'
);
$settings_btn->signal_connect(clicked => sub { $self->_settings_dialog() });
$tb->insert($settings_btn, -1);
return $tb;
}
sub _build_sidebar {
my ($self) = @_;
my $sw = Gtk3::ScrolledWindow->new();
$sw->set_policy('automatic', 'automatic');
$sw->set_size_request(100, 1);
$sw->set_propagate_natural_height(FALSE);
# TreeStore: label (str), type (str: 'category'|'artist'|'album'|'folder'),
# value (str: artist name, album name, folder_id)
my $store = Gtk3::TreeStore->new('Glib::String', 'Glib::String', 'Glib::String');
$self->sidebar_store($store);
my $view = Gtk3::TreeView->new($store);
$view->set_headers_visible(FALSE);
$view->get_selection()->set_mode('single');
$view->signal_connect('cursor-changed' => sub { $self->_sidebar_activated($view) });
$view->signal_connect('button-press-event' => sub { $self->_sidebar_button_press($view, $_[1]) });
$self->sidebar_view($view);
$view->set_size_request(100, 1);
my $renderer = Gtk3::CellRendererText->new();
$renderer->set(ellipsize => 'end');
my $col = Gtk3::TreeViewColumn->new_with_attributes('', $renderer, text => 0);
$col->set_sizing('fixed');
$col->set_expand(TRUE);
$view->append_column($col);
$view->set_fixed_height_mode(TRUE);
$sw->add($view);
my $hbox = Gtk3::Box->new('horizontal', 0);
$hbox->pack_start($self->_build_alpha_strip(), FALSE, FALSE, 0);
$hbox->pack_start($sw, TRUE, TRUE, 0);
return $hbox;
}
sub _build_alpha_strip {
my ($self) = @_;
my $css = Gtk3::CssProvider->new();
$css->load_from_data(
'treeview.alpha-nav { font-size: 10px; padding: 0; }'
. ' treeview.alpha-nav row { min-height: 0; padding: 1px 0; }'
);
my $store = Gtk3::ListStore->new('Glib::String');
for my $letter ('#', 'A' .. 'Z') {
my $iter = $store->append();
$store->set($iter, 0, $letter);
}
my $view = Gtk3::TreeView->new($store);
$view->set_headers_visible(FALSE);
$view->set_fixed_height_mode(TRUE);
$view->set_can_focus(FALSE);
$view->set_activate_on_single_click(TRUE);
$view->get_style_context()->add_class('alpha-nav');
$view->get_style_context()->add_provider($css, 600);
$self->alpha_view($view);
my $renderer = Gtk3::CellRendererText->new();
$renderer->set(xalign => 0.5);
my $col = Gtk3::TreeViewColumn->new_with_attributes('', $renderer, text => 0);
$col->set_sizing('fixed');
$col->set_fixed_width(32);
$view->append_column($col);
$view->signal_connect('row-activated' => sub {
my ($tv, $path, $col) = @_;
my $iter = $store->get_iter($path) or return;
my $letter = $store->get($iter, 0);
$self->_sidebar_jump_to_letter($letter);
$tv->get_selection()->unselect_all();
});
my $sw = Gtk3::ScrolledWindow->new();
$sw->set_policy('never', 'automatic');
$sw->set_size_request(32, 1);
$sw->set_propagate_natural_height(FALSE);
$sw->add($view);
return $sw;
}
sub _sidebar_jump_to_letter {
my ($self, $letter) = @_;
my $cat_label = $self->_alpha_category or return;
my $store = $self->sidebar_store;
my $view = $self->sidebar_view;
my $cat_iter = $store->get_iter_first() or return;
my $target;
while (1) {
if (($store->get($cat_iter, 0) // '') eq $cat_label) {
$target = $store->get_path($cat_iter); # snapshot path before iter moves
last;
}
last unless $store->iter_next($cat_iter);
}
return unless $target;
my $target_iter = $store->get_iter($target) or return;
my $n = $store->iter_n_children($target_iter);
for my $i (0 .. $n - 1) {
my $child = $store->iter_nth_child($target_iter, $i) or next;
my $label = $store->get($child, 0) // '';
my $first = uc(substr($label, 0, 1));
my $matches = $letter eq '#' ? ($first lt 'A' || $first gt 'Z')
: $first eq $letter;
next unless $matches;
my $path = $store->get_path($child);
$view->expand_to_path($path);
$view->set_cursor($path, undef, FALSE);
$view->scroll_to_cell($path, undef, TRUE, 0.0, 0.0);
return;
}
}
sub _navigate_sidebar_to {
my ($self, $kind, $value) = @_;
my $cat_label = $kind eq 'artist' ? 'Artists'
: $kind eq 'album' ? 'Albums'
: $kind eq 'genre' ? 'Genres'
: return;
my $store = $self->sidebar_store;
my $view = $self->sidebar_view;
# Locate the category header (top-level row whose label matches).
my $cat_iter = $store->get_iter_first() or return;
my $cat_path;
while (1) {
if (($store->get($cat_iter, 0) // '') eq $cat_label) {
$cat_path = $store->get_path($cat_iter);
last;
}
last unless $store->iter_next($cat_iter);
}
return unless $cat_path;
# Match against the raw value stored in column 2, not the display label
# in column 0 (which may carry LRM markers from _display_text/_album).
my $cat_iter2 = $store->get_iter($cat_path) or return;
my $n = $store->iter_n_children($cat_iter2);
for my $i (0 .. $n - 1) {
my $child = $store->iter_nth_child($cat_iter2, $i) or next;
my $stored = $store->get($child, 2);
next unless defined $stored && $stored eq $value;
my $path = $store->get_path($child);
$view->expand_to_path($path);
$view->set_cursor($path, undef, FALSE);
$view->scroll_to_cell($path, undef, TRUE, 0.5, 0.0);
return;
}
}
sub _update_alpha_category {
my ($self, $label) = @_;
my $enabled = defined $label
&& $label =~ / \A (?: Artists | Albums | Genres ) \z /x;
$self->_alpha_category($enabled ? $label : undef);
$self->alpha_view->set_sensitive($enabled ? TRUE : FALSE)
if $self->alpha_view;
}
sub _build_tracklist {
my ($self) = @_;
my $vbox = Gtk3::Box->new('vertical', 0);
my $sw = Gtk3::ScrolledWindow->new();
$sw->set_policy('automatic', 'automatic');
$sw->set_size_request(-1, 1);
$sw->set_propagate_natural_height(FALSE);
$sw->set_kinetic_scrolling(FALSE);
$sw->set_capture_button_press(FALSE);
$sw->set_overlay_scrolling(FALSE);
$vbox->pack_start($sw, TRUE, TRUE, 0);
my $count_lbl = Gtk3::Label->new('');
$count_lbl->set_xalign(1.0);
$count_lbl->set_margin_end(6);
$count_lbl->set_margin_top(2);
$count_lbl->set_margin_bottom(2);
$self->track_count_label($count_lbl);
$vbox->pack_start($count_lbl, FALSE, FALSE, 0);
# ListStore columns: id, track#, title, artist, album, genre, year, duration_str, drive_id
my $store = Gtk3::ListStore->new(
'Glib::Int', # 0 db id
'Glib::String', # 1 track#
'Glib::String', # 2 title
'Glib::String', # 3 artist
'Glib::String', # 4 album
'Glib::String', # 5 genre
'Glib::String', # 6 year
'Glib::String', # 7 duration
'Glib::String', # 8 drive_id
);
$self->track_store($store);
my $view = Gtk3::TreeView->new($store);
$view->set_headers_visible(TRUE);
$view->set_fixed_height_mode(TRUE);
$view->get_selection()->set_mode('multiple');
$view->signal_connect('row-activated' => sub { $self->_track_activated(@_) });
$self->track_view($view);
my @cols = (
['#', 1, 40],
['Title', 2, 220],
lib/App/DrivePlayer/GUI.pm view on Meta::CPAN
return $frame;
}
sub _icon_button {
my ($self, $icon_name, $cb) = @_;
my $btn = Gtk3::Button->new();
$btn->set_image(Gtk3::Image->new_from_icon_name($icon_name, 'button'));
$btn->signal_connect(clicked => $cb);
return $btn;
}
# ---- Library loading ----
sub _load_library {
my ($self) = @_;
$self->_populate_sidebar();
$self->_populate_tracklist($self->db->all_tracks());
my $count = $self->db->track_count();
$self->_set_status("$count tracks in library");
}
# Rebuild the sidebar tree (so renamed/added/dropped artists, albums, and
# genres are reflected) without losing the user's current selection or
# kicking off a tracklist reload. Used after a metadata edit when an
# artist/album/genre changed.
sub _refresh_sidebar_keep_selection {
my ($self) = @_;
my $store = $self->sidebar_store;
my $view = $self->sidebar_view;
my $sel;
if (my ($path) = $view->get_cursor()) {
if (my $iter = $store->get_iter($path)) {
$sel = {
type => $store->get($iter, 1),
value => $store->get($iter, 2),
};
}
}
$self->_suppress_sidebar_activated(1);
$self->_populate_sidebar();
if ($sel) {
my $cat = $store->get_iter_first;
TOP: while ($cat) {
my $check = sub {
my $iter = shift;
return ($store->get($iter, 1) // '') eq ($sel->{type} // '')
&& ($store->get($iter, 2) // '') eq ($sel->{value} // '');
};
if ($check->($cat)) {
$view->set_cursor($store->get_path($cat), undef, FALSE);
last TOP;
}
my $n = $store->iter_n_children($cat);
for my $i (0 .. $n - 1) {
my $child = $store->iter_nth_child($cat, $i) or next;
next unless $check->($child);
my $cp = $store->get_path($child);
$view->expand_to_path($cp);
$view->set_cursor($cp, undef, FALSE);
last TOP;
}
last unless $store->iter_next($cat);
}
}
$self->_suppress_sidebar_activated(0);
}
sub _populate_sidebar {
my ($self) = @_;
my $store = $self->sidebar_store;
$store->clear();
# All Tracks
my $all_iter = $store->append(undef);
$store->set($all_iter, 0, 'All Tracks', 1, 'all', 2, '');
# Artists
my $art_iter = $store->append(undef);
$store->set($art_iter, 0, 'Artists', 1, 'category', 2, '');
for my $artist ($self->db->all_artists()) {
my $iter = $store->append($art_iter);
$store->set($iter, 0, _display_text($artist), 1, 'artist', 2, $artist);
}
# Albums
my $alb_iter = $store->append(undef);
$store->set($alb_iter, 0, 'Albums', 1, 'category', 2, '');
for my $album ($self->db->all_albums()) {
my $iter = $store->append($alb_iter);
$store->set($iter, 0, _display_album($album), 1, 'album', 2, $album);
}
# Genres
my $gen_iter = $store->append(undef);
$store->set($gen_iter, 0, 'Genres', 1, 'category', 2, '');
for my $genre ($self->db->all_genres()) {
my $iter = $store->append($gen_iter);
$store->set($iter, 0, $genre, 1, 'genre', 2, $genre);
}
# Folders
my $fld_iter = $store->append(undef);
$store->set($fld_iter, 0, 'Folders', 1, 'category', 2, '');
for my $sf ($self->db->all_scan_folders()) {
my $iter = $store->append($fld_iter);
$store->set($iter, 0, $sf->{name}, 1, 'folder', 2, $sf->{drive_id});
}
$self->sidebar_view->expand_all();
}
sub _populate_tracklist {
my ($self, @tracks) = @_;
my $store = $self->track_store;
$store->clear();
$self->_track_iter_map({});
$self->_track_by_id({});
$self->_playlist(\@tracks);
$self->_playing_row_ref(undef);
$self->_playing_track_id(undef);
for my $t (@tracks) {
my $iter = $store->append();
$store->set($iter,
0, $t->{id} // 0,
1, _track_num_str($t->{track_number}),
2, _display_title($t->{title}) || '(Unknown)',
3, _display_text($t->{artist}),
4, _display_album($t->{album}),
5, $t->{genre} // '',
6, $t->{year} // '',
7, _dur_str($t->{duration_ms}),
8, $t->{drive_id} // '',
);
if ($t->{id}) {
$self->_track_iter_map->{$t->{id}} = $iter;
$self->_track_by_id->{$t->{id}} = $t;
}
}
my $n = scalar @tracks;
$self->track_count_label->set_text($n == 1 ? '1 track' : "$n tracks");
}
sub _refresh_track_row {
my ($self, $track_id) = @_;
my $iter = $self->_track_iter_map->{$track_id} or return;
my $t = $self->db->get_track($track_id) or return;
$self->track_store->set($iter,
1, _track_num_str($t->{track_number}),
2, _display_title($t->{title}) || '(Unknown)',
3, _display_text($t->{artist}),
4, _display_album($t->{album}),
5, $t->{genre} // '',
6, $t->{year} // '',
7, _dur_str($t->{duration_ms}),
);
# Keep the cached hashref in sync so subsequent edits start from the
# new values rather than the stale pre-edit snapshot.
$self->_track_by_id->{$track_id} = $t;
}
# ---- Playback ----
sub _track_activated {
my ($self, $view, $path, $col) = @_;
$self->_play_at_path($path, no_scroll => 1);
}
lib/App/DrivePlayer/GUI.pm view on Meta::CPAN
$total_removed += $result->{removed_tracks};
}
}
my $done_msg = "Done. $track_count tracks";
$done_msg .= ", $total_removed removed" if $total_removed > 0;
$done_msg .= '.';
$progress->set_fraction(1.0);
$status_lbl->set_text($done_msg);
Gtk3::main_iteration_do(FALSE) while Gtk3::events_pending();
sleep 1;
$dlg->destroy();
$self->_load_library();
$self->_sync_with_sheet() unless $stopped;
}
# ---- Dialogs ----
sub _settings_dialog {
my ($self) = @_;
$self->_settings_open(1);
my $dlg = Gtk3::Dialog->new_with_buttons(
'Settings', $self->win,
[qw/ modal destroy-with-parent /],
'Save', 'ok',
'Cancel', 'cancel',
);
$dlg->set_default_size(520, -1);
my $vbox = $dlg->get_content_area();
$vbox->set_spacing(0);
# ---- Google API credentials ----
my $auth_frame = Gtk3::Frame->new('Google API Credentials');
$auth_frame->set_border_width(8);
my $grid = Gtk3::Grid->new();
$grid->set_row_spacing(8);
$grid->set_column_spacing(8);
$grid->set_border_width(8);
$auth_frame->add($grid);
$vbox->pack_start($auth_frame, FALSE, FALSE, 0);
my $row = 0;
my %entries;
for my $field (
['client_id', 'OAuth Client ID:',
'OAuth 2.0 Client ID from Google Cloud Console (Desktop app type).'],
['client_secret', 'OAuth Client Secret:',
'OAuth 2.0 Client Secret paired with the Client ID above.'],
['token_file', 'Token File:',
'Path to the OAuth2 token created by running '
. 'google_restapi_oauth_token_creator.'],
) {
my ($key, $lbl, $tip) = @$field;
my $lbl_w = Gtk3::Label->new($lbl);
$lbl_w->set_xalign(1.0);
$lbl_w->set_tooltip_text($tip);
$grid->attach($lbl_w, 0, $row, 1, 1);
my $e = Gtk3::Entry->new();
$e->set_hexpand(TRUE);
$e->set_text($self->config->auth_config()->{$key} // '');
$e->set_visibility(FALSE) if $key eq 'client_secret';
$e->set_tooltip_text($tip);
$grid->attach($e, 1, $row, 1, 1);
$entries{$key} = $e;
$row++;
}
my $auth_note = Gtk3::Label->new();
$auth_note->set_markup(
'<span size="small" foreground="#555555">'
. 'Create a Desktop-app OAuth client at '
. '<a href="https://console.cloud.google.com/apis/credentials">'
. 'Google Cloud Console â Credentials</a>, then enable the '
. '<a href="https://console.cloud.google.com/apis/library/drive.googleapis.com">'
. 'Drive API</a>. Generate the token file by running '
. '<tt>google_restapi_oauth_token_creator</tt> in a terminal.'
. '</span>'
);
$auth_note->set_xalign(0.0);
$auth_note->set_line_wrap(TRUE);
$auth_note->set_max_width_chars(60);
$grid->attach($auth_note, 1, $row, 1, 1);
# ---- Google Sheet sync ----
my $sheet_frame = Gtk3::Frame->new('Google Sheet Sync');
$sheet_frame->set_border_width(8);
my $sheet_grid = Gtk3::Grid->new();
$sheet_grid->set_row_spacing(8);
$sheet_grid->set_column_spacing(8);
$sheet_grid->set_border_width(8);
$sheet_frame->add($sheet_grid);
$vbox->pack_start($sheet_frame, FALSE, FALSE, 0);
my $sid_lbl = Gtk3::Label->new('Spreadsheet ID:');
$sid_lbl->set_xalign(1.0);
$sheet_grid->attach($sid_lbl, 0, 0, 1, 1);
my $sid_box = Gtk3::Box->new('horizontal', 6);
my $sid_entry = Gtk3::Entry->new();
$sid_entry->set_hexpand(TRUE);
$sid_entry->set_text($self->config->sheet_id());
$sid_entry->set_placeholder_text('Paste spreadsheet ID, or click Find or Create');
$sid_entry->set_tooltip_text(
'The spreadsheet ID is the long string between "/d/" and "/edit" '
. 'in a Google Sheets URL. Leave blank and click "Find or Create" '
. 'to have DrivePlayer find or create a sheet for you.'
);
$sid_lbl->set_tooltip_text($sid_entry->get_tooltip_text());
$sid_box->pack_start($sid_entry, TRUE, TRUE, 0);
my $create_btn = Gtk3::Button->new_with_label('Find or Createâ¦');
$create_btn->set_tooltip_text('Use existing DrivePlayer Library spreadsheet, or create one');
$create_btn->signal_connect(clicked => sub {
return unless $self->_init_api();
$create_btn->set_sensitive(FALSE);
$create_btn->set_label('Searchingâ¦');
Gtk3::main_iteration_do(FALSE) while Gtk3::events_pending();
my $id;
my @found = eval {
$self->drive->list(
filter => "name='DrivePlayer Library' and mimeType='application/vnd.google-apps.spreadsheet' and trashed=false",
params => { fields => 'files(id,name)', pageSize => 1 },
);
};
if ($@) {
$self->_show_error("Drive search failed:\n$@");
} elsif (@found) {
$id = $found[0]{id};
} else {
$create_btn->set_label('Creatingâ¦');
Gtk3::main_iteration_do(FALSE) while Gtk3::events_pending();
my $sheet = App::DrivePlayer::SheetDB->new(api => $self->rest_api);
$id = eval { $sheet->create() };
$self->_show_error("Failed to create spreadsheet:\n$@") if $@;
}
$sid_entry->set_text($id) if $id;
$create_btn->set_label('Find or Createâ¦');
$create_btn->set_sensitive(TRUE);
});
$sid_box->pack_start($create_btn, FALSE, FALSE, 0);
$sheet_grid->attach($sid_box, 1, 0, 1, 1);
my $sheet_note = Gtk3::Label->new();
$sheet_note->set_markup(
'<span size="small" foreground="#555555">'
. 'The library syncs to this sheet automatically after each '
. 'Library â Sync. Useful for sharing metadata across devices or '
. 'editing tags in '
. '<a href="https://sheets.google.com/">Google Sheets</a>.'
. '</span>'
);
$sheet_note->set_xalign(0.0);
$sheet_note->set_line_wrap(TRUE);
$sheet_note->set_max_width_chars(60);
$sheet_grid->attach($sheet_note, 1, 1, 1, 1);
# ---- Acoustic fingerprinting ----
my $fp_frame = Gtk3::Frame->new('Acoustic Fingerprinting (AcoustID)');
$fp_frame->set_border_width(8);
lib/App/DrivePlayer/GUI.pm view on Meta::CPAN
$install_btn->set_tooltip_text(
'Installs libchromaprint-tools via apt (requires administrator password)'
);
my $fp_hbox = Gtk3::Box->new('horizontal', 8);
$fp_hbox->pack_start($fp_status, FALSE, FALSE, 0);
$fp_hbox->pack_start($install_btn, FALSE, FALSE, 0);
$fp_grid->attach($fp_hbox, 1, 0, 1, 1);
# Helper: refresh the fpcalc status label
my $refresh_fp_status = sub {
if (App::DrivePlayer::MetadataFetcher::fpcalc_available()) {
$fp_status->set_markup('<span foreground="#2d862d"><b>Installed</b></span>');
$install_btn->hide();
}
else {
$fp_status->set_markup(
'<span foreground="#cc0000">Not installed</span>'
. ' <span size="small" foreground="#666666">'
. '(needed for fingerprint-based lookup)</span>'
);
$install_btn->show();
}
};
$refresh_fp_status->();
$install_btn->signal_connect(clicked => sub {
$install_btn->set_sensitive(FALSE);
$fp_status->set_markup('<span foreground="#666666">Installingâ¦</span>');
Gtk3::main_iteration_do(FALSE) while Gtk3::events_pending();
my $pid = fork();
if (!defined $pid) {
$fp_status->set_markup('<span foreground="#cc0000">Fork failed</span>');
$install_btn->set_sensitive(TRUE);
return;
}
if ($pid == 0) {
exec('pkexec', 'apt-get', 'install', '-y', 'libchromaprint-tools')
or POSIX::_exit(1);
}
# Poll every 500 ms until the child exits
Glib::Timeout->add(500, sub {
my $res = waitpid($pid, WNOHANG());
if ($res == $pid) {
$refresh_fp_status->();
$install_btn->set_sensitive(TRUE);
return FALSE; # remove timer
}
return TRUE; # keep polling
});
});
# AcoustID API key
my $aid_lbl = Gtk3::Label->new('AcoustID API Key:');
$aid_lbl->set_xalign(1.0);
$fp_grid->attach($aid_lbl, 0, 1, 1, 1);
my $aid_entry = Gtk3::Entry->new();
$aid_entry->set_hexpand(TRUE);
$aid_entry->set_text($self->config->acoustid_key());
$aid_entry->set_placeholder_text('Get a free key at acoustid.org');
$aid_entry->set_tooltip_text(
'Free AcoustID API key â used with fpcalc to look up missing tags '
. 'by acoustic fingerprint.'
);
$aid_lbl->set_tooltip_text($aid_entry->get_tooltip_text());
$fp_grid->attach($aid_entry, 1, 1, 1, 1);
$fp_lbl->set_tooltip_text(
'fpcalc (from chromaprint) generates audio fingerprints for '
. 'acoustic-ID lookup. Install it via apt if missing.'
);
my $aid_note = Gtk3::Label->new();
$aid_note->set_markup(
'<span size="small" foreground="#555555">'
. 'Register a free application at '
. '<a href="https://acoustid.org/new-application">acoustid.org</a> '
. 'to obtain a key. '
. '<a href="https://acoustid.org/">More info</a>.'
. '</span>'
);
$aid_note->set_xalign(0.0);
$aid_note->set_line_wrap(TRUE);
$aid_note->set_max_width_chars(60);
$fp_grid->attach($aid_note, 1, 2, 1, 1);
# ---- Config file path (informational) ----
my $info_grid = Gtk3::Grid->new();
$info_grid->set_row_spacing(4);
$info_grid->set_column_spacing(8);
$info_grid->set_border_width(8);
$vbox->pack_start($info_grid, FALSE, FALSE, 0);
my $cfg_key = Gtk3::Label->new('Config file:');
$cfg_key->set_xalign(1.0);
$info_grid->attach($cfg_key, 0, 0, 1, 1);
my $cfg_lbl = Gtk3::Label->new($self->config->config_file());
$cfg_lbl->set_xalign(0.0);
$cfg_lbl->set_selectable(TRUE);
$info_grid->attach($cfg_lbl, 1, 0, 1, 1);
$dlg->show_all();
# Re-apply visibility after show_all (show_all overrides hide())
$refresh_fp_status->();
my $response = $dlg->run();
if ($response eq 'ok') {
my $auth = $self->config->auth_config();
my %before = map { $_ => ($auth->{$_} // '') } keys %entries;
for my $key (keys %entries) {
$auth->{$key} = $entries{$key}->get_text();
}
$self->config->_data->{acoustid_key} = $aid_entry->get_text();
$self->config->_data->{sheet_id} = $sid_entry->get_text();
$self->config->save();
my $auth_changed = grep {
lib/App/DrivePlayer/GUI.pm view on Meta::CPAN
$self->_edit_metadata_dialog($track) if $track;
});
$menu->append($edit_item);
$menu->show_all();
$menu->popup_at_pointer($event);
}
sub _edit_metadata_dialog {
my ($self, $track) = @_;
my $dlg = Gtk3::Dialog->new_with_buttons(
'Edit Metadata', $self->win,
[qw/ modal destroy-with-parent /],
'Save', 'ok',
'Cancel', 'cancel',
);
$dlg->set_default_size(460, 280);
my $grid = Gtk3::Grid->new();
$grid->set_row_spacing(6);
$grid->set_column_spacing(8);
$grid->set_border_width(12);
$dlg->get_content_area()->add($grid);
# LRM (U+200E) is an invisible left-to-right marker. Prepending it to
# an Entry's text forces the internal PangoLayout's base direction to
# LTR, which keeps short RTL content (Arabic, Hebrew, â¦) flush with the
# left edge instead of the right. The glyphs within still render in
# their natural direction, so "راغب Ø¹ÙØ§Ù
Ø©" still looks correct.
# We strip the marker on read so it never reaches the DB or a query.
my $LRM = "\x{200E}";
my $put = sub {
my ($entry, $val) = @_;
$val //= '';
$entry->set_text(length $val ? $LRM . $val : '');
};
my $get = sub {
my ($entry) = @_;
my $v = $entry->get_text();
$v =~ s/[\x{200E}\x{200F}]//g; # LRM + RLM
return $v;
};
my %entries;
my $row = 0;
for my $field (
[ title => 'Title:' ],
[ artist => 'Artist:' ],
[ album => 'Album:' ],
[ genre => 'Genre:' ],
[ track_number => 'Track Number:' ],
[ year => 'Year:' ],
[ comment => 'Comment:' ],
) {
my ($key, $lbl) = @$field;
my $label = Gtk3::Label->new($lbl);
$label->set_xalign(1.0);
$grid->attach($label, 0, $row, 1, 1);
my $entry = Gtk3::Entry->new();
$entry->set_hexpand(TRUE);
$entry->set_direction('ltr');
$entry->set_alignment(0.0);
$put->($entry, $track->{$key});
$grid->attach($entry, 1, $row, 1, 1);
$entries{$key} = $entry;
$row++;
}
my $fetch_btn = Gtk3::Button->new_with_label('Fetch');
$fetch_btn->set_halign('end');
$fetch_btn->set_tooltip_text(
'Look up missing fields online (text search, then AcoustID '
. 'fingerprint). Only blank fields are filled â to refresh a '
. 'populated field, clear it first.'
);
# Treat whitespace-only fields as blank for both the query we build and
# the emptiness test below â a stray space is almost never what the user
# meant and it would otherwise block the fetch from filling the field.
my $trimmed = sub {
my $s = shift // '';
$s =~ s/\A\s+//;
$s =~ s/\s+\z//;
return $s;
};
$fetch_btn->signal_connect(clicked => sub {
# Build a track-like hashref from current dialog contents so the
# lookup uses whatever the user has typed so far.
my %current = (drive_id => $track->{drive_id});
for my $key (keys %entries) {
my $val = $trimmed->($get->($entries{$key}));
$current{$key} = length $val ? $val : undef;
}
$fetch_btn->set_sensitive(FALSE);
$fetch_btn->set_label('Fetchingâ¦');
Gtk3::main_iteration_do(FALSE) while Gtk3::events_pending();
my $meta = $self->_lookup_metadata(\%current);
$fetch_btn->set_label('Fetch');
$fetch_btn->set_sensitive(TRUE);
return unless $meta;
my @filled;
for my $key (keys %entries) {
my $cur = $trimmed->($get->($entries{$key}));
my $new = $meta->{$key};
if ($log) {
$log->debug(sprintf
'Fetch fill %s: cur=[%s] meta=[%s]',
$key, $cur, $new // '(undef)');
}
next if length $cur;
next unless defined $new && length $new;
$put->($entries{$key}, "$new"); # LRM-prefixed, LTR layout
push @filled, $key;
}
Gtk3::main_iteration_do(FALSE) while Gtk3::events_pending();
$log->debug('Fetch filled: ' . (@filled ? join(',', @filled) : '(none)'))
if $log;
( run in 0.735 second using v1.01-cache-2.11-cpan-5623c5533a1 )