App-DrivePlayer

 view release on metacpan or  search on metacpan

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


sub _build_db {
    my ($self) = @_;
    return App::DrivePlayer::DB->new(path => $self->config->db_path());
}

sub BUILD {
    my ($self) = @_;
    $self->_init_logging();
}

sub run {
    my ($self) = @_;
    my $db_is_new = !-f $self->config->db_path();
    $self->_build_ui();
    $self->_auto_sync_from_sheet_on_new_db() if $db_is_new;
    $self->_prune_removed_folders();
    $self->_load_library();

    Glib::Timeout->add($POLL_INTERVAL_MS, sub {
        $self->_player_poll();
        return TRUE;
    });

    Gtk3->main();
    $self->player->quit() if $self->player;
}

# ---- Initialisation ----

sub _init_logging {
    my ($self) = @_;
    $self->config->ensure_dirs();
    my $level = $self->config->log_level();
    my $file  = $self->config->log_file() // '/tmp/drive_player.log';

    my $log4perl_conf = "
        log4perl.rootLogger=$level, Screen, File
        log4perl.appender.Screen=Log::Log4perl::Appender::Screen
        log4perl.appender.Screen.layout=Log::Log4perl::Layout::PatternLayout
        log4perl.appender.Screen.layout.ConversionPattern=%d [%p] %m%n
        log4perl.appender.File=Log::Log4perl::Appender::File
        log4perl.appender.File.filename=$file
        log4perl.appender.File.utf8=1
        log4perl.appender.File.layout=Log::Log4perl::Layout::PatternLayout
        log4perl.appender.File.layout.ConversionPattern=%d [%p] %m%n
    ";
    if (eval { require Log::Log4perl; 1 }) {
        Log::Log4perl->init(\$log4perl_conf);
        binmode STDERR, ':encoding(UTF-8)';
    }
}

sub _init_api {
    my ($self) = @_;
    return $self->rest_api if $self->rest_api;

    my $auth_cfg = $self->config->auth_config();
    unless ($auth_cfg->{client_id} && $auth_cfg->{client_secret}) {
        $self->_show_error(
            "Google API credentials not configured.\n\n" .
            "Open File > Settings and enter your OAuth Client ID and Secret.\n\n" .
            "You can obtain these from the Google Cloud Console under\n" .
            "APIs & Services > Credentials (OAuth 2.0 Client ID, Desktop app type)."
        );
        return;
    }
    unless (-f ($auth_cfg->{token_file} // '')) {
        $self->_show_error("OAuth token file not found: $auth_cfg->{token_file}\n\n" .
            "Run the token creator from p5-google-restapi:\n" .
            "  bin/google_restapi_oauth_token_creator");
        return;
    }

    my $api = eval { Google::RestApi->new(auth => $auth_cfg) };
    if ($@) {
        $self->_show_error("Failed to initialise Google API: $@");
        return;
    }
    $self->rest_api($api);
    $self->drive(Google::RestApi::DriveApi3->new(api => $api));

    $self->player(App::DrivePlayer::Player->new(
        auth            => $api->auth(),
        on_track_end    => sub { $self->_on_track_end() },
        on_position     => sub { $self->_on_position(@_) },
        on_state_change => sub { $self->_on_state_change(@_) },
    ));

    return $self->rest_api;
}

sub _reinit_api {
    my ($self) = @_;
    if ($self->player) {
        $self->player->quit();
        $self->player(undef);
    }
    $self->rest_api(undef);
    $self->drive(undef);
    return $self->_init_api();
}

# ---- UI Construction ----

sub _build_ui {
    my ($self) = @_;

    Gtk3::Window::set_default_icon_name('multimedia-player');

    $self->win(Gtk3::Window->new('toplevel'));
    $self->win->set_title('Drive Player');
    $self->win->set_default_size(900, 600);
    $self->win->signal_connect(destroy => sub { $self->_quit() });

    my $vbox = Gtk3::Box->new('vertical', 0);
    $self->win->add($vbox);

    # Menu bar
    $vbox->pack_start($self->_build_menubar(), FALSE, FALSE, 0);

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

            my $confirm = Gtk3::MessageDialog->new(
                $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();

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

    my $response = $dlg->run();
    $dlg->destroy();
    return unless $response eq 'yes';

    for my $sf ($self->db->all_scan_folders()) {
        $self->db->delete_scan_folder($sf->{drive_id});
    }
    $self->_load_library();
    $self->_set_status('Library cleared.');
}

# ---- Helpers ----

sub _update_now_playing {
    my ($self, $track) = @_;
    my $text = '';
    $text .= _display_text($track->{artist}) . ' — '  if $track->{artist};
    $text .= _display_title($track->{title}) || '(Unknown)';
    $text .= '  [' . _display_album($track->{album}) . ']' if $track->{album};
    $self->now_playing_label->set_text($text);
    $self->win->set_title("Drive Player — $text");
}

sub _highlight_path {
    my ($self, $path) = @_;
    my $view = $self->track_view;
    my $sel  = $view->get_selection();
    $sel->unselect_all();
    $sel->select_path($path);

    # Only scroll if the row isn't already visible — re-centring an on-screen
    # row is the "extra scrolling" the user sees.
    my ($first, $last) = $view->get_visible_range();
    return if $first && $last
           && $path->compare($first) >= 0
           && $path->compare($last)  <= 0;

    $view->scroll_to_cell($path, undef, TRUE, 0.5, 0.0);
}

sub _set_status {
    my ($self, $msg) = @_;
    $self->statusbar->pop($self->_status_ctx);
    $self->statusbar->push($self->_status_ctx, $msg);
}

sub _show_error {
    my ($self, $msg) = @_;
    my $dlg = Gtk3::MessageDialog->new(
        $self->win, 'destroy-with-parent', 'error', 'ok', $msg
    );
    $dlg->run();
    $dlg->destroy();

    if (_is_auth_error($msg) && !$self->_settings_open) {
        $self->_settings_dialog();
    }
}

# Heuristic: does an error message look like a Google API auth/connection
# failure that the user can fix in the Settings dialog (bad credentials,
# expired/revoked token, etc.)?
sub _is_auth_error {
    my ($msg) = @_;
    return 0 unless defined $msg && length $msg;

    return 1 if $msg =~ m{
        \b (?: 401 | 403 ) \b           # unauthorized / forbidden
        | \b invalid _ grant \b
        | \b invalid _ token \b
        | \b invalid _ client \b
        | \b token \b .* \b (?: expired | revoked ) \b
        | \b (?: expired | revoked ) \b .* \b token \b
        | unauthenti(?: cated | c )
    }xi;

    return 1 if $msg =~ m{ \b 400 \b }x
        && $msg =~ m{
            \b (?: token | grant | oauth | credential s? | auth ) \b
        }xi;

    return 0;
}

sub _quit {
    my ($self) = @_;
    $self->_stop_metadata_fetch() if $self->_meta_watch_id;
    $self->player->quit() if $self->player;
    Gtk3->main_quit();
}

# ---- Formatting helpers ----

sub _dur_str {
    my ($ms) = @_;
    return '' unless defined $ms && $ms > 0;
    return _sec_str($ms / 1000);
}

sub _sec_str {
    my ($sec) = @_;
    return '0:00' unless defined $sec;
    $sec = int($sec);
    my $m = int($sec / 60);
    my $s = $sec % 60;
    return sprintf("%d:%02d", $m, $s);
}

sub _track_num_str {
    my ($n) = @_;
    return '' unless defined $n && $n > 0;
    return sprintf("%02d", $n);
}

# Display-only cosmetics: turn underscores back into spaces for
# title / artist / album strings that came from filenames.  The DB
# keeps the raw value so lookups and sheet-sync continue to match.
sub _display_text {
    my ($s) = @_;
    return '' unless defined $s && length $s;
    $s =~ tr/_/ /;

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

}

# Title-specific: strip a leading track-number prefix like "01 ",
# "01 - ", "01. ", "01-" before applying the underscore transform.
# Accepts 1-3 digit numbers so compilation tracks up to 999 still
# get cleaned.
sub _display_title {
    my ($s) = @_;
    return '' unless defined $s && length $s;
    # Swap underscores to spaces first so "01_Attention" becomes "01 Attention"
    # and the track-number prefix below can match it.
    $s =~ tr/_/ /;
    $s =~ s{
        \A
        \s*              # optional leading whitespace
        \d{1,3}          # 1-3 digit track number
        [\s.\-]+         # separator: space, dot, hyphen
    }{}x;
    return $s;
}

1;

__END__

=head1 NAME

App::DrivePlayer::GUI - GTK3 application window for DrivePlayer

=head1 SYNOPSIS

  use App::DrivePlayer::GUI;

  App::DrivePlayer::GUI->new->run;

=head1 DESCRIPTION

The top-level L<Moo> class that constructs and drives the GTK3 user
interface.  Responsibilities include:

=over 4

=item *

Building the main window with a sidebar (artists / albums / folders), a
track list, and playback controls (play/pause, stop, seek, volume).

=item *

Lazily initialising the Google REST API connection and
L<App::DrivePlayer::Player> on first use, so start-up is fast even when network
access is unavailable.

=item *

Running folder scans (via L<App::DrivePlayer::Scanner>) in a background thread
with live progress reporting.

=item *

Persisting configuration changes (music folder list, OAuth2 credentials)
through L<App::DrivePlayer::Config>.

=back

Requires the GTK3 system libraries and the L<Gtk3> and L<Glib> Perl
modules.  Not covered by the unit test suite.

=head1 METHODS

=head2 new

  my $gui = App::DrivePlayer::GUI->new;

Constructs the application object.  The window is not shown until L</run>
is called.

=head2 run

  $gui->run;

Build and display the main window, then enter the GTK3 main loop.  Does not
return until the window is closed.

=cut



( run in 0.477 second using v1.01-cache-2.11-cpan-cdf2f3d4e48 )