App-DrivePlayer
view release on metacpan or search on metacpan
against the new config, so a refreshed token takes
effect without restarting the app.
- Errors: HTTP 4xx / OAuth-grant failures from Google now
pop the Settings dialog automatically after the error
is dismissed, routing the user straight to the
credentials. Re-entry from errors fired inside the
Settings dialog itself (e.g. Find-or-Create) is
suppressed.
- Tracklist: Artist, Album and Genre cells are now click
targets that switch the sidebar to that group; the
existing cursor-changed signal repopulates the
tracklist. A hand cursor on hover indicates the cell
is clickable.
- Edit metadata: when an artist / album / genre value
changes and other tracks share the previous value, a
single confirmation dialog offers to apply the rename
across all matching tracks -- so one mojibake fix can
clean up the lingering sidebar entry instead of leaving
it stuck behind unrenamed siblings.
- Edit metadata: small grey help label under the Fetch
button states that Fetch only fills blank fields, and
that clearing a populated field lets Fetch replace it.
only on sheet -> Drive is queried and the track is either
added to the DB (exists) or removed from the sheet (gone).
Drive API errors during the existence check preserve the row
on both sides -- sync never destroys data on an API error.
- Removed the separate File -> Sync from Sheet / Sync to Sheet
menu items. Auto-push after metadata edits/fetches is kept
as a targeted merge-push, since running a full two-way sync
after every field edit would be wasteful.
- Fix: clicking a track in the list no longer auto-scrolls the
viewport between the two clicks of a double-click, which
could cause a different track to end up under the cursor and
be played instead of the one clicked. The tracklist now
handles left-clicks manually so GTK's set_cursor auto-scroll
never fires on click.
0.2.4 2026-04-17
- Sheet push is now a merge instead of a full replace: local
non-blank values overwrite the sheet, local blanks preserve
existing sheet values, and drive_ids only present on the sheet
are kept intact. Prevents a device with partially-populated
metadata from wiping data pushed from another device.
- No longer auto-fetches metadata on startup. If another device
has already populated metadata and synced it to the sheet, a
lib/App/DrivePlayer/GUI.pm view on Meta::CPAN
$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);
lib/App/DrivePlayer/GUI.pm view on Meta::CPAN
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'
lib/App/DrivePlayer/GUI.pm view on Meta::CPAN
# 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);
lib/App/DrivePlayer/GUI.pm view on Meta::CPAN
$c->set_resizable(TRUE);
$c->set_sort_column_id($idx);
$c->set_sizing('fixed');
$c->set_fixed_width($width);
$view->append_column($c);
$link_kind{$c} = $link_kind_by_col_idx{$idx}
if exists $link_kind_by_col_idx{$idx};
}
# Take full control of left-click so GTK's default handler (which
# calls set_cursor and auto-scrolls the clicked row into view) doesn't
# shift the viewport between the clicks of a double-click.
$view->signal_connect('button-press-event' => sub {
my ($w, $event) = @_;
if ($event->button == 3) {
$self->_tracklist_context_menu($event);
return TRUE;
}
return FALSE unless $event->button == 1;
my ($path, $col) = $w->get_path_at_pos($event->x, $event->y);
return FALSE unless $path;
if ($event->type eq '2button-press') {
$self->_play_at_path($path, no_scroll => 1);
return TRUE;
}
# Single-click on a "link" cell (Artist / Album / Genre) jumps the
# sidebar to that group. The sidebar's cursor-changed signal then
# repopulates the tracklist, so no further work is needed here.
if ($col && (my $kind = $link_kind{$col})) {
my $iter = $self->track_store->get_iter($path);
my $id = $iter ? $self->track_store->get($iter, 0) : undef;
my $track = $id ? $self->_track_by_id->{$id} : undef;
my $value = $track ? $track->{$kind} : undef;
if (defined $value && length $value) {
$self->_navigate_sidebar_to($kind, $value);
return TRUE;
}
lib/App/DrivePlayer/GUI.pm view on Meta::CPAN
# Let GTK handle shift-click range extension.
return FALSE;
}
else {
$sel->unselect_all();
$sel->select_path($path);
}
return TRUE;
});
# Hover affordance: show the link/hand cursor over Artist and Album
# cells that hold a value, so users see those columns are clickable.
my $is_link_cursor = 0;
my $set_cursor = sub {
my ($want_link) = @_;
return if $want_link == $is_link_cursor;
my $window = $view->get_bin_window() or return;
if ($want_link) {
my $display = $window->get_display();
my $cursor = Gtk3::Gdk::Cursor->new_for_display($display, 'hand2');
$window->set_cursor($cursor);
} else {
$window->set_cursor(undef);
}
$is_link_cursor = $want_link;
};
$view->signal_connect('motion-notify-event' => sub {
my ($w, $event) = @_;
my ($path, $col) = $w->get_path_at_pos($event->x, $event->y);
my $want_link = 0;
if ($path && $col && (my $kind = $link_kind{$col})) {
my $iter = $self->track_store->get_iter($path);
my $id = $iter ? $self->track_store->get($iter, 0) : undef;
my $track = $id ? $self->_track_by_id->{$id} : undef;
my $value = $track ? $track->{$kind} : undef;
$want_link = 1 if defined $value && length $value;
}
$set_cursor->($want_link);
return FALSE;
});
$view->signal_connect('leave-notify-event' => sub {
$set_cursor->(0);
return FALSE;
});
$sw->add($view);
return $vbox;
}
sub _build_searchbar {
my ($self) = @_;
my $hbox = Gtk3::Box->new('horizontal', 4);
lib/App/DrivePlayer/GUI.pm view on Meta::CPAN
# 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) = @_;
lib/App/DrivePlayer/GUI.pm view on Meta::CPAN
eval { $self->player->poll() };
$log->warn("Player poll error: $@") if $@ && $log;
}
}
# ---- Sidebar activation ----
sub _sidebar_activated {
my ($self, $view) = @_;
return if $self->_suppress_sidebar_activated;
my ($path) = $view->get_cursor();
return unless $path;
my $store = $self->sidebar_store;
my $iter = $store->get_iter($path);
my $type = $store->get($iter, 1);
my $value = $store->get($iter, 2);
my $label = $store->get($iter, 0);
# Resolve the alpha-nav category for the current selection. Leaf rows look
# up their parent category's label; category headers use their own label.
my $cat_label;
( run in 1.948 second using v1.01-cache-2.11-cpan-cdf2f3d4e48 )