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 )