App-DrivePlayer

 view release on metacpan or  search on metacpan

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

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

    # Toolbar
    $vbox->pack_start($self->_build_toolbar(), FALSE, FALSE, 0);

    # Main paned: sidebar | tracklist
    my $paned = Gtk3::Paned->new('horizontal');
    $paned->set_position(220);
    $vbox->pack_start($paned, TRUE, TRUE, 0);

    $paned->pack1($self->_build_sidebar(),   TRUE, TRUE);
    $paned->pack2($self->_build_tracklist(), TRUE, TRUE);

    # Search bar
    $vbox->pack_start($self->_build_searchbar(), FALSE, FALSE, 0);

    # Player controls
    $vbox->pack_start($self->_build_controls(), FALSE, FALSE, 0);

    # Status bar
    $self->statusbar(Gtk3::Statusbar->new());
    $self->_status_ctx($self->statusbar->get_context_id('main'));
    $vbox->pack_start($self->statusbar, FALSE, FALSE, 0);

    $self->win->show_all();
    $self->stop_btn->hide();   # hidden until playback starts
}

sub _build_menubar {
    my ($self) = @_;
    my $mb = Gtk3::MenuBar->new();

    # File menu
    my $file_menu = Gtk3::Menu->new();
    $self->_add_menu_item($file_menu, 'Add Music Folder…',  sub { $self->_add_folder_dialog() });
    $self->_add_menu_item($file_menu, 'Manage Folders…',    sub { $self->_manage_folders_dialog() });
    $file_menu->append(Gtk3::SeparatorMenuItem->new());
    $self->_add_menu_item($file_menu, 'Settings…',          sub { $self->_settings_dialog() });
    $file_menu->append(Gtk3::SeparatorMenuItem->new());
    $self->_add_menu_item($file_menu, 'Quit',               sub { $self->_quit() });
    my $file_item = Gtk3::MenuItem->new_with_label('File');
    $file_item->set_submenu($file_menu);
    $mb->append($file_item);

    # Library menu
    my $lib_menu = Gtk3::Menu->new();
    $self->_add_menu_item($lib_menu, 'Sync',                   sub { $self->_sync_all() });
    $self->_add_menu_item($lib_menu, 'Refresh',               sub { $self->_load_library() });
    $lib_menu->append(Gtk3::SeparatorMenuItem->new());
    my $fetch_item = $self->_add_menu_item($lib_menu, 'Fetch All Metadata', sub { $self->_toggle_metadata_fetch() });
    $self->_meta_fetch_item($fetch_item);
    $self->_add_menu_item($lib_menu, 'Retry Incomplete Metadata', sub { $self->_retry_incomplete_metadata() });
    $self->_add_menu_item($lib_menu, 'Reset Metadata Fetch',      sub { $self->_reset_metadata_fetch() });
    $lib_menu->append(Gtk3::SeparatorMenuItem->new());
    $self->_add_menu_item($lib_menu, 'Clear Library',           sub { $self->_clear_library() });
    my $lib_item = Gtk3::MenuItem->new_with_label('Library');
    $lib_item->set_submenu($lib_menu);
    $mb->append($lib_item);

    # Playback menu
    my $pb_menu = Gtk3::Menu->new();
    $self->_add_menu_item($pb_menu, 'Play / Pause',  sub { $self->_toggle_play() });
    $self->_add_menu_item($pb_menu, 'Stop',          sub { $self->_stop() });
    $self->_add_menu_item($pb_menu, 'Next Track',    sub { $self->_next_track() });
    $self->_add_menu_item($pb_menu, 'Previous Track',sub { $self->_prev_track() });
    my $pb_item = Gtk3::MenuItem->new_with_label('Playback');
    $pb_item->set_submenu($pb_menu);
    $mb->append($pb_item);

    return $mb;
}

sub _add_menu_item {
    my ($self, $menu, $label, $cb) = @_;
    my $item = Gtk3::MenuItem->new_with_label($label);
    $item->signal_connect(activate => $cb);
    $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);

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

    }
}

# ---- 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;
    if ($type eq 'category') {
        $cat_label = $label;
    } elsif ($type =~ / \A (?: artist | album | genre ) \z /x) {
        my $parent = $store->iter_parent($iter);
        $cat_label = $parent ? $store->get($parent, 0) : undef;
    }
    $self->_update_alpha_category($cat_label);

    if ($type eq 'all') {
        $self->_populate_tracklist($self->db->all_tracks());
    } elsif ($type eq 'artist') {
        $self->_populate_tracklist($self->db->tracks_by_artist($value));
    } elsif ($type eq 'album') {
        $self->_populate_tracklist($self->db->tracks_by_album($value));
    } elsif ($type eq 'genre') {
        $self->_populate_tracklist($self->db->tracks_by_genre($value));
    } elsif ($type eq 'folder') {
        my $sf = $self->db->get_scan_folder_by_drive_id($value) or return;
        $self->_populate_tracklist($self->db->tracks_by_scan_folder($sf->{id}));
    } else {
        return;  # category header selected — no tracklist change
    }

    $self->_set_status(scalar(@{ $self->_playlist }) . ' tracks');
}

sub _sidebar_button_press {
    my ($self, $view, $event) = @_;
    return FALSE unless $event->button == 3;

    my ($path) = $view->get_path_at_pos($event->x, $event->y);
    return FALSE unless $path;

    my $store = $self->sidebar_store;
    my $iter  = $store->get_iter($path);
    my $type  = $store->get($iter, 1);
    return FALSE unless $type eq 'folder';

    my $drive_id = $store->get($iter, 2);
    my $sf       = $self->db->get_scan_folder_by_drive_id($drive_id) or return FALSE;

    my $menu = Gtk3::Menu->new();
    $self->_add_menu_item($menu, 'Fetch Metadata for This Folder', sub {
        $self->_stop_metadata_fetch() if $self->_meta_watch_id;
        $self->_fetch_all_metadata($sf->{id});
    });
    $menu->show_all();
    $menu->popup_at_pointer($event);
    return TRUE;
}

# ---- Search ----

sub _on_search {
    my ($self, $query) = @_;
    if (length $query >= 2) {
        $self->_populate_tracklist($self->db->search_tracks($query));
    } elsif (length $query == 0) {
        $self->_populate_tracklist($self->db->all_tracks());
    }
}

# ---- Scanning ----

sub _sync_all {
    my ($self) = @_;
    return unless $self->_init_api();

    my @folders = @{ $self->config->music_folders() };
    unless (@folders) {
        $self->_show_error("No music folders configured.\nUse File → Add Music Folder.");
        return;
    }

    $self->_show_sync_dialog(\@folders);
}

sub _show_sync_dialog {
    my ($self, $folders) = @_;

    my $dlg = Gtk3::Dialog->new_with_buttons(
        'Syncing Library', $self->win,
        [qw/ modal destroy-with-parent /],
        'Stop', 'cancel',
    );
    $dlg->set_default_size(400, 160);

    my $content = $dlg->get_content_area();
    my $vbox = Gtk3::Box->new('vertical', 8);
    $vbox->set_border_width(12);
    $content->pack_start($vbox, TRUE, TRUE, 0);

    my $status_lbl = Gtk3::Label->new('Preparing…');
    $status_lbl->set_xalign(0.0);
    $status_lbl->set_ellipsize('middle');
    $vbox->pack_start($status_lbl, FALSE, FALSE, 0);

    my $progress = Gtk3::ProgressBar->new();
    $progress->set_pulse_step(0.05);
    $vbox->pack_start($progress, FALSE, FALSE, 0);

    my $count_lbl = Gtk3::Label->new('0 tracks found');

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

    );
    $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 {
            $before{$_} ne ($auth->{$_} // '')
        } keys %entries;

        if ($auth_changed && $self->rest_api) {
            $self->_reinit_api();
            $self->_set_status('Settings saved. Reconnected to Google Drive.');
        } else {
            $self->_set_status('Settings saved.');
        }
    }
    $dlg->destroy();
    $self->_settings_open(0);
}

sub _tracklist_context_menu {
    my ($self, $event) = @_;

    my ($path) = $self->track_view->get_path_at_pos($event->x, $event->y);
    return unless $path;
    $self->track_view->get_selection()->select_path($path);
    my $track = $self->_track_at_path($path);

    my $menu = Gtk3::Menu->new();

    my $play_item = Gtk3::MenuItem->new_with_label('Play');
    $play_item->signal_connect(activate => sub { $self->_play_at_path($path) });
    $menu->append($play_item);

    my $edit_item = Gtk3::MenuItem->new_with_label('Edit…');
    $edit_item->signal_connect(activate => sub {
        $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);



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