App-DrivePlayer

 view release on metacpan or  search on metacpan

lib/App/DrivePlayer/GUI.pm  view on Meta::CPAN

        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);
    $hbox->set_border_width(2);

    my $label = Gtk3::Label->new('Search:');
    $hbox->pack_start($label, FALSE, FALSE, 4);

    my $entry = Gtk3::SearchEntry->new();
    $entry->set_placeholder_text('Artist, album or title…');
    $entry->signal_connect('search-changed' => sub { $self->_on_search($entry->get_text()) });
    $self->search_entry($entry);
    $hbox->pack_start($entry, TRUE, TRUE, 0);

    my $clear = Gtk3::Button->new_with_label('Clear');
    $clear->signal_connect(clicked => sub {
        $entry->set_text('');
        $self->_load_library();
    });
    $hbox->pack_start($clear, FALSE, FALSE, 0);

    return $hbox;
}

sub _build_controls {
    my ($self) = @_;
    my $frame = Gtk3::Frame->new();
    my $vbox  = Gtk3::Box->new('vertical', 2);
    $vbox->set_border_width(4);
    $frame->add($vbox);

    # Now-playing label
    $self->now_playing_label(Gtk3::Label->new('Not playing'));
    $self->now_playing_label->set_ellipsize('end');
    $self->now_playing_label->set_xalign(0.0);
    $vbox->pack_start($self->now_playing_label, FALSE, FALSE, 0);

    # Progress bar + time labels
    my $prog_hbox = Gtk3::Box->new('horizontal', 4);
    $self->time_label(Gtk3::Label->new('0:00'));
    $self->time_label->set_size_request(40, -1);
    $prog_hbox->pack_start($self->time_label, FALSE, FALSE, 0);

    $self->progress(Gtk3::Scale->new_with_range('horizontal', 0, 100, 1));
    $self->progress->set_draw_value(FALSE);
    $self->progress->set_range(0, 1);
    $self->progress->signal_connect('button-press-event' => sub {
        $self->_progress_dragging(1); return FALSE;
    });
    $self->progress->signal_connect('button-release-event' => sub {
        $self->_progress_dragging(0);
        $self->player->seek($self->progress->get_value()) if $self->player;
        return FALSE;
    });
    $prog_hbox->pack_start($self->progress, TRUE, TRUE, 0);

    $self->dur_label(Gtk3::Label->new('0:00'));
    $self->dur_label->set_size_request(40, -1);
    $prog_hbox->pack_start($self->dur_label, FALSE, FALSE, 0);
    $vbox->pack_start($prog_hbox, FALSE, FALSE, 0);

    # Buttons + volume
    my $btn_hbox = Gtk3::Box->new('horizontal', 4);
    $vbox->pack_start($btn_hbox, FALSE, FALSE, 0);

    $self->prev_btn($self->_icon_button('media-skip-backward', sub { $self->_prev_track() }));
    $self->play_btn($self->_icon_button('media-playback-start', sub { $self->_toggle_play() }));
    $self->stop_btn($self->_icon_button('media-playback-stop',  sub { $self->_stop() }));
    $self->next_btn($self->_icon_button('media-skip-forward',   sub { $self->_next_track() }));

    $btn_hbox->pack_start($self->prev_btn, FALSE, FALSE, 0);
    $btn_hbox->pack_start($self->play_btn, FALSE, FALSE, 0);
    $btn_hbox->pack_start($self->stop_btn, FALSE, FALSE, 0);
    $btn_hbox->pack_start($self->next_btn, FALSE, FALSE, 0);

    $btn_hbox->pack_start(Gtk3::Label->new(' Vol:'), FALSE, FALSE, 8);
    $self->vol_scale(Gtk3::Scale->new_with_range('horizontal', 0, 100, 1));
    $self->vol_scale->set_value(80);
    $self->vol_scale->set_size_request(100, -1);
    $self->vol_scale->set_draw_value(FALSE);
    $self->vol_scale->signal_connect('value-changed' => sub {
        $self->player->set_volume($self->vol_scale->get_value()) if $self->player;
    });
    $btn_hbox->pack_start($self->vol_scale, FALSE, FALSE, 0);

    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);

lib/App/DrivePlayer/GUI.pm  view on Meta::CPAN

                $self->win, 'destroy-with-parent', 'warning', 'yes-no',
                "$count tracks would be removed from \"$folder_name\".\n\nProceed with deletion?",
            );
            my $response = $confirm->run();
            $confirm->destroy();
            return $response eq 'yes';
        },
    );
    $self->scanner($scanner);

    $dlg->signal_connect(response => sub {
        $stopped = TRUE;
        $scanner->stop();
    });

    for my $folder (@$folders) {
        last if $stopped;
        $current++;
        $status_lbl->set_text("Syncing folder $current/$total: $folder->{name}");
        $progress->set_fraction($current / ($total + 1));
        Gtk3::main_iteration_do(FALSE) while Gtk3::events_pending();

        my $result = eval { $scanner->scan_folder($folder->{id}, $folder->{name}) };
        if ($@) {
            $self->_set_status("Error syncing $folder->{name}: $@");
        } else {
            $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);
    my $fp_grid = Gtk3::Grid->new();
    $fp_grid->set_row_spacing(8);
    $fp_grid->set_column_spacing(8);
    $fp_grid->set_border_width(8);
    $fp_frame->add($fp_grid);
    $vbox->pack_start($fp_frame, FALSE, FALSE, 0);

    # fpcalc status row
    my $fp_lbl = Gtk3::Label->new('fpcalc:');
    $fp_lbl->set_xalign(1.0);
    $fp_grid->attach($fp_lbl, 0, 0, 1, 1);

    my $fp_status = Gtk3::Label->new();
    $fp_status->set_xalign(0.0);

    my $install_btn = Gtk3::Button->new_with_label('Install…');
    $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
            }



( run in 2.403 seconds using v1.01-cache-2.11-cpan-e1769b4cff6 )