1# Copyright (C) 2005-2015 Quentin Sculo <squentin@free.fr> 2# 3# This file is part of Gmusicbrowser. 4# Gmusicbrowser is free software; you can redistribute it and/or modify 5# it under the terms of the GNU General Public License version 3, as 6# published by the Free Software Foundation 7 8BEGIN 9{ require 'oggheader.pm'; 10 require 'mp3header.pm'; 11 require 'flacheader.pm'; 12 require 'mpcheader.pm'; 13 require 'apeheader.pm'; 14 require 'wvheader.pm'; 15 require 'm4aheader.pm'; 16} 17use strict; 18use warnings; 19use utf8; 20 21package FileTag; 22 23our %FORMATS; 24 25INIT 26{ 27 %FORMATS= # module format string tags to look for (order is important) 28 ( mp3 => ['Tag::MP3', 'mp3 l{layer}v{versionid}', 'ID3v2 APE lyrics3v2 ID3v1',], 29 oga => ['Tag::OGG', 'vorbis v{version}', 'vorbis',], 30 flac => ['Tag::Flac', 'flac', 'vorbis',], 31 mpc => ['Tag::MPC', 'mpc v{version}', 'APE ID3v2 lyrics3v2 ID3v1',], 32 ape => ['Tag::APEfile', 'ape v{version}', 'APE ID3v2 lyrics3v2 ID3v1',], 33 wv => ['Tag::WVfile', 'wv v{version}', 'APE ID3v1',], 34 m4a => ['Tag::M4A', 'mp4 {traktype}', 'ilst',], 35); 36 $FORMATS{$_}=$FORMATS{ $::Alias_ext{$_} } for keys %::Alias_ext; 37} 38 39sub Read 40{ my ($file,$findlength,$fieldlist)=@_; 41 return unless $file=~m/\.([^.]+)$/; 42 warn "Reading tags for $file".($findlength ? " findlength=$findlength" :'').($fieldlist ? " fieldlist=$fieldlist" :'')."\n" if $::debug; 43 my $format=$FORMATS{lc $1}; 44 return unless $format; 45 my ($package,$formatstring,$plist)=@$format; 46 my $filetag= eval { $package->new($file,$findlength); }; #filelength==1 -> may return estimated length (mp3 only) 47 unless ($filetag) { warn $@ if $@; warn "Can't read tags for $file\n"; return } 48 49 ::setlocale(::LC_NUMERIC, 'C'); 50 my @taglist; 51 my %values; #results will be put in %values 52 if (my $info=$filetag->{info}) #audio properties 53 { if ($findlength!=1 && $info->{estimated}) { delete $info->{$_} for qw/seconds bitrate estimated/; } 54 $formatstring=~s/{(\w+)}/$info->{$1}/g; 55 $values{filetype}=$formatstring; 56 for my $f (grep $Songs::Def{$_}{audioinfo}, @Songs::Fields) 57 { for my $key (split /\|/,$Songs::Def{$f}{audioinfo}) 58 { my $v=$info->{$key}; 59 if (defined $v) {$values{$f}=$v; last} 60 } 61 } 62 } 63 for my $tag (split / /,$plist) 64 { if ($tag eq 'vorbis' || $tag eq 'ilst') 65 { push @taglist, $tag => $filetag; 66 } 67 elsif ($filetag->{$tag}) 68 { push @taglist, lc($tag) => $filetag->{$tag}; 69 if ($tag eq 'ID3v2' && $filetag->{ID3v2s}) 70 { push @taglist, id3v2 => $_ for @{ $filetag->{ID3v2s} }; 71 } 72 } 73 } 74 my @fields= $fieldlist ? split /\s+/, $fieldlist : 75 grep $Songs::Def{$_}{flags}=~m/r/, @Songs::Fields; 76 for my $field (@fields) 77 { for (my $i=0; $i<$#taglist; $i+=2) 78 { my $id=$taglist[$i]; #$id is type of tag : id3v1 id3v2 ape vorbis lyrics3v2 ilst 79 my $tag=$taglist[$i+1]; 80 my $value; 81 my $def=$Songs::Def{$field}; 82 if (defined(my $keys=$def->{$id})) #generic cases 83 { my $joinwith= $def->{join_with}; 84 my $split=$def->{read_split}; 85 my $join= $def->{flags}=~m/l/ || defined $joinwith; 86 for my $key (split /\s*[|&]\s*/,$keys) 87 { if ($key=~m#%i#) 88 { my $userid= $def->{userid}; 89 next unless defined $userid && length $userid; 90 $key=~s#%i#$userid#; 91 } 92 my $func='postread'; 93 $func.=":$1" if $key=~s/^(\w+)\(\s*([^)]+?)\s*\)$/$2/; #for tag-specific postread function 94 my $fpms_id; $fpms_id=$1 if $key=~m/FMPS_/ && $key=~s/::(.+)$//; 95 my @v= $tag->get_values($key); 96 next unless @v; 97 if (defined $fpms_id) { @v= (FMPS_hash_read($v[0],$fpms_id)); next unless @v; } 98 if (my $sub= $def->{$func}||$def->{postread}) 99 { @v= map $sub->($_,$id,$key,$field), @v; 100 next unless @v; 101 } 102 if ($join) { push @$value, grep defined, @v; } 103 else { $value= $v[0]; last; } 104 } 105 next unless defined $value; 106 if (defined $joinwith) { $value= join $joinwith,@$value; } 107 elsif (defined $split) { $value= [map split($split,$_), @$value]; } 108 } 109 elsif (my $sub=$def->{"$id:read"}) #special cases with custom function 110 { $values{$field}= $sub->($tag); 111 last; 112 } 113 if (defined $value) { $values{$field}=$value; last } 114 } 115 } 116 ::setlocale(::LC_NUMERIC, ''); 117 118 return \%values; 119} 120 121sub Write 122{ my ($file,$modif,$errorsub)=@_; warn "FileTag::Write($file,[@$modif],$errorsub)\n" if $::debug; 123 124 my ($format)= $file=~m/\.([^.]*)$/; 125 return unless $format and $format=$FileTag::FORMATS{lc$format}; 126 ::setlocale(::LC_NUMERIC, 'C'); 127 my $tag= $format->[0]->new($file); 128 unless ($tag) {warn "can't read tags for $file\n";return } 129 130 my ($maintag)=split / /,$format->[2],2; 131 if (($maintag eq 'ID3v2' && !$::Options{TAG_id3v1_noautocreate}) || $tag->{ID3v1}) 132 { my $id3v1 = $tag->{ID3v1} ||= $tag->new_ID3v1; 133 my $i=0; 134 while ($i<$#$modif) 135 { my $field=$modif->[$i++]; 136 my $val= $modif->[$i++]; 137 my $n=$Songs::Def{$field}{id3v1}; 138 next unless defined $n; 139 $id3v1->[$n]= $val; # for genres $val is a arrayref 140 } 141 } 142 143 my @taglist; 144 if ($maintag eq 'ID3v2' || $tag->{ID3v2}) 145 { my @id3tags= ($tag->{ID3v2} || $tag->new_ID3v2); 146 push @id3tags, @{$tag->{ID3v2s}} if $tag->{ID3v2s}; 147 for my $id3tag (@id3tags) 148 { my ($ver)= $id3tag->{version}=~m/^(\d+)/; 149 push @taglist, ["id3v2.$ver",'id3v2'], $id3tag; 150 } 151 } 152 if ($maintag eq 'vorbis' || $maintag eq 'ilst') 153 { push @taglist, $maintag,$tag; 154 } 155 if ($maintag eq 'APE' || $tag->{APE}) 156 { my $ape = $tag->{APE} || $tag->new_APE; 157 push @taglist, 'ape', $ape; 158 } 159 while (@taglist) 160 { my ($id,$tag)=splice @taglist,0,2; 161 my @ids= (ref $id ? @$id : ($id)); 162 unshift @ids, map "$_:write", @ids; 163 my $i=0; 164 while ($i<$#$modif) 165 { my $field=$modif->[$i++]; 166 my $vals= $modif->[$i++]; 167 $vals=[$vals] unless ref $vals; 168 my $def=$Songs::Def{$field}; 169 my ($keys)= grep defined, map $def->{$_}, @ids; 170 next unless defined $keys; 171 if (ref $keys) # custom ":write" functions 172 { my @todo=$keys->($vals); 173 while (@todo) 174 { my ($key,$val)=splice @todo,0,2; 175 if (defined $val) { $tag->insert($key,$val) } 176 else { $tag->remove_all($key) } 177 } 178 next; 179 } 180 181 my $userid= $def->{userid}; 182 my ($wkey,@keys)= split /\s*\|\s*/,$keys; 183 my $toremove= @keys; #these keys will be removed 184 push @keys, split /\s*&\s*/, $wkey; #these keys will be updated (first one and ones separated by &) 185 for my $key (@keys) 186 { if ($key=~m/%i/) { next unless defined $userid && length $userid; $key=~s#%i#$userid#g } 187 my $func='prewrite'; 188 $func.=":$1" if $key=~s/^(\w+)\(\s*([^)]+?)\s*\)$/$2/; #for tag-specific prewrite function "function( TAG )" 189 my $sub= $def->{$func} || $def->{'prewrite'}; 190 my @v= @$vals; 191 if ($toremove-- >0) { @v=(); } #remove "deprecated" keys 192 elsif ($sub) 193 { @v= map $sub->($_,$ids[-1],$key,$field), @v; 194 } 195 if ($key=~m/FMPS_/ && $key=~s/::(.+)$//) # FMPS list field such as FMPS_Rating_User 196 { my $v= FMPS_hash_write( $tag, $key, $1, $v[0] ); 197 @v= $v eq '' ? () : ($v); 198 } 199 $tag->remove_all($key); 200 $tag->insert($key,$_) for reverse grep defined, @v; 201 } 202 } 203 } 204 205 $tag->{errorsub}=$errorsub; 206 $tag->write_file unless $::CmdLine{ro} || $::CmdLine{rotags}; 207 ::setlocale(::LC_NUMERIC, ''); 208 return 1; 209} 210 211sub FMPS_string_to_hash 212{ my $vlist=shift; 213 my %h; 214 for my $pair (split /;;/, $vlist) 215 { my ($key,$value)= split /::/,$pair,2; 216 s#\\([;:\\])#$1#g for $key,$value; 217 $h{$key}=$value; 218 } 219 return \%h; 220} 221sub FMPS_hash_to_string 222{ my $h=shift; 223 my @list; 224 for my $key (sort keys %$h) 225 { my $v=$h->{$key}; 226 s#([;:\\])#\\$1#g for $key,$v; 227 push @list, $key.'::'.$v; 228 } 229 return join ';;',@list; 230} 231sub FMPS_hash_read 232{ my ($vlist,$id)=@_; 233 return unless $vlist; 234 my $h= FMPS_string_to_hash($vlist); 235 my $v=$h->{$id}; 236 return defined $v ? ($v) : (); 237} 238sub FMPS_hash_write 239{ my ($tag,$key,$id,$value)=@_; 240 my ($vlist)= $tag->get_values($key); 241 my $h= FMPS_string_to_hash( $vlist||'' ); 242 if (defined $value) { $h->{$id}=$value; } 243 else { delete $h->{$id}; } 244 return FMPS_hash_to_string($h); 245} 246 247sub PixFromMusicFile 248{ my ($file,$nb,$quiet,$return_number)=@_; 249 if ($file=~s/:(\w+)$//) {$nb=$1} # index can be specified as argument or in the filename 250 my ($h)=Read($file,0,'embedded_pictures'); 251 return unless $h; 252 my $pix= $h->{embedded_pictures}; 253 unless ($pix && @$pix) {warn "no picture found in $file\n" unless $quiet;return;} 254 #FIXME filter out mimetype of "-->" (link) ? 255 256 return ref $pix->[0] ? (map $pix->[$_][3],0..$#$pix) : @$pix if wantarray; 257 258 if (!defined $nb) { $nb=0 } 259 elsif ($nb=~m/\D/) 260 { if (ref $pix->[0]) #for APIC structures 261 { my $apic_id= $Songs::Def{$nb} && $Songs::Def{$nb}{apic_id}; 262 if ($apic_id) 263 { ($nb)= grep $pix->[$_][1]==$apic_id ,0..$#$pix; 264 return unless defined $nb; 265 } 266 return unless defined $nb; 267 } 268 elsif ($nb eq 'album') { $nb=0 } 269 else { return } 270 } 271 elsif ($nb>$#$pix) { $nb=0 } 272 273 return $nb if $return_number; 274 return ref $pix->[0] ? $pix->[$nb][3] : $pix->[$nb]; 275} 276 277sub GetLyrics 278{ my $ID=shift; 279 my $file= Songs::GetFullFilename($ID); 280 my ($h)=Read($file,0,'embedded_lyrics'); 281 return unless $h; 282 my $lyrics= $h->{embedded_lyrics}; 283 warn "no lyrics found in $file\n" unless $lyrics; 284 return $lyrics; 285} 286 287sub WriteLyrics 288{ my ($ID,$lyrics)=@_; 289 Write($ID, [embedded_lyrics=>$lyrics], sub 290 { my ($syserr,$details)= Error_Message(@_); 291 return ::Retry_Dialog($syserr, _"Error writing lyrics", details=>$details, ID=>$ID); 292 }); 293} 294 295#convert error details from tag writing to translated string with utf8 filenames 296sub Error_Message 297{ my ($syserr,$type,$file)=@_; 298 my $details= $type eq 'openwrite' ? 299 ::__x(_"Error opening '{file}' for writing.",file=>::filename_to_utf8displayname($file)) : 300 'Unknown error'; #currently $type is always "openwrite" 301 return $syserr,$details; 302} 303 304package MassTag; 305 306use constant { TRUE => 1, FALSE => 0, }; 307 308our @FORMATS; 309our @FORMATS_user; 310our @Tools; 311INIT 312{ 313 @Tools= 314 ( { label=> _"Capitalize", for_all => sub { ucfirst lc $_[0]; }, }, 315 { label=>_"Capitalize each word", for_all => sub { join '',map ucfirst lc, split /(\W+)/,$_[0]; }, }, 316 ); 317 @FORMATS= 318 ( ['%a - %l - %n - %t', qr/(.+) - (.+) - (\d+) - (.+)$/], 319 ['%a_-_%l_-_%n_-_%t', qr/(.+)_-_(.+)_-_(\d+)_-_(.+)$/], 320 ['%n - %a - %l - %t', qr/(\d+) - (.+) - (.+) - (.+)$/], 321 ['(%a) - %l - %n - %t', qr/\((.+)\) - (.+) - (\d+) - (.+)$/], 322 ['%a - %l - %n-%t', qr/(.+) - (.+) - (\d+)-(.+)$/], 323 ['%a-%l-%n-%t', qr/(.+)-(.+)-(\d+)-(.+)$/], 324 ['%a - %l-%n. %t', qr/(.+) - (.+)-(\d+). (.+)$/], 325 ['%l - %n - %t', qr/([^-]+) - (\d+) - (.+)$/], 326 ['%a - %n - %t', qr/([^-]+) - (\d+) - (.+)$/], 327 ['%n - %l - %t', qr/(\d+) - (.+) - (.+)$/], 328 ['%n - %a - %t', qr/(\d+) - (.+) - (.+)$/], 329 ['(%n) %a - %t', qr/\((\d+)\) (.+) - (.+)$/], 330 ['%n-%a-%t', qr/(\d+)-(.+)-(.+)$/], 331 ['%n %a %t', qr/(\d+) (.+) (.+)$/], 332 ['%a - %n %t', qr/(.+) - (\d+) ([^-].+)$/], 333 ['%l - %n %t', qr/(.+) - (\d+) ([^-].+)$/], 334 ['%n - %t', qr/(\d+) - (.+)$/], 335 ['%d%n - %t', qr/(\d)(\d\d) - (.+)$/], 336 ['%n_-_%t', qr/(\d+)_-_(.+)$/], 337 ['(%n) %t', qr/\((\d+)\) (.+)$/], 338 ['%n_%t', qr/(\d+)_(.+)$/], 339 ['%n-%t', qr/(\d+)-(.+)$/], 340 ['%d%n-%t', qr/(\d)(\d\d)-(.+)$/], 341 ['%d-%n-%t', qr/(\d)-(\d+)-(.+)$/], 342 ['cd%d-%n-%t', qr/cd(\d+)-(\d+)-(.+)$/i], 343 ['Disc %d - %n - %t', qr/Disc (\d+) - (\d+) - (.+)$/i], 344 ['%n %t - %a - %l', qr/(\d+) (.+) - (.+) - (.+)$/], 345 ['%n %t - %l - %a', qr/(\d+) (.+) - (.+) - (.+)$/], 346 ['%n. %a - %t', qr/(\d+)\. (.+) - (.+)$/], 347 ['%n. %t', qr/(\d+)\. (.+)$/], 348 ['%n %t', qr/(\d+) ([^-].+)$/], 349 ['Track%n', qr/[Tt]rack ?-? ?(\d+)/], 350 ['%n', qr/^(\d+)$/], 351 ['%a - %t', qr/(\D.+) - (.+)$/], 352 ['%n - %a,%t', qr/(\d+) - (.+?),(.+)$/], 353 #['TEST : %a %n %t',qr/(.+)(?: *|_)\W(?: *|_)(\d+)(?: *|_)\W(?: *|_)(.+)/], 354 #['TEST : %n %t',qr/(\d+)(?: *|_)\W(?: *|_)(.+)/], 355 ); 356# my %swap=(a => 'l', l => 'a',); 357# my @tmp; 358# for my $ref (@FORMATS) 359# { my ($f,$re)=@$ref; 360# push @tmp,$ref; 361# if ($f=~s/%([al])/%$swap{$1}/g) { push @tmp,[$f,$re] } 362# } 363# @FORMATS=@tmp; 364} 365 366use base 'Gtk2::Box'; 367sub new 368{ my ($class,@IDs) = @_; 369 @IDs= ::uniq(@IDs); 370 my $self = bless Gtk2::VBox->new, $class; 371 372 my $table=Gtk2::Table->new (6, 2, FALSE); 373 my $row1=my $row2=0; 374 my %widgets; 375 $self->{widgets}=\%widgets; 376 $self->{pf_widgets}={}; 377 $self->{IDs}=\@IDs; 378 379 # folder name at the top 380 { my $folders= Songs::UniqList('path',\@IDs); 381 my $folder=$folders->[0]; 382 my $displaysub= Songs::DisplayFromHash_sub('path'); 383 if (@$folders>1) 384 { my $common= ::find_common_parent_folder(@$folders); 385 $folder=_"different folders"; 386 $folder.= "\n". ::__x(_"(common parent folder : {common})",common=> $displaysub->($common) ) if length($common)>5; 387 } 388 my $text= ::__n("%d file in {folder}","%d files in {folder}",scalar@IDs); 389 $text= ::__x($text, folder => ::MarkupFormat('<small>%s</small>', $displaysub->($folder) ) ); 390 my $labelfile = Gtk2::Label->new; 391 $labelfile->set_markup($text); 392 $labelfile->set_selectable(TRUE); 393 $labelfile->set_line_wrap(TRUE); 394 $self->pack_start($labelfile, FALSE, TRUE, 2); 395 } 396 397 for my $field ( Songs::EditFields('many') ) 398 { my $check=Gtk2::CheckButton->new(Songs::FieldName($field)); 399 my $widget=Songs::EditWidget($field,'many',\@IDs); 400 next unless $widget; 401 $widgets{$field}=$widget; 402 $check->{widget}=$widget; 403 $widget->set_sensitive(FALSE); 404 405 $check->signal_connect( toggled => sub { my $check=shift; $check->{widget}->set_sensitive( $check->get_active ); }); 406 my ($row,$col)= $widget->{noexpand} ? ($row2++,2) : ($row1++,0); 407 $table->attach($check,$col++,$col,$row,$row+1,'fill','shrink',3,1); 408 $table->attach($widget,$col++,$col,$row,$row+1,['fill','expand'],'shrink',3,1); 409 } 410 411 my $vpaned= $self->{vpaned}=Gtk2::VPaned->new; 412 $self->add($vpaned); 413 my $sw=Gtk2::ScrolledWindow->new; 414 $sw->set_shadow_type('none'); 415 $sw->set_policy('never', 'automatic'); 416 $sw->add_with_viewport($table); 417 $sw->show_all; 418 $sw->set_size_request(-1,$table->size_request->height); 419 $vpaned->pack1($sw,FALSE,TRUE); 420 421 # do not add per-file part if LOTS of songs, building the GUI would be too long anyway 422 $self->add_per_file_part unless @IDs>1000; 423 $self->set_size_request(-1,400); #to allow resizing the window to a small height in spite of the height request of $sw 424 return $self; 425} 426 427# for edition of file-specific tags (track title ...) 428sub add_per_file_part 429{ my $self=shift; 430 my $IDs=$self->{IDs}; 431 Songs::SortList($IDs,'path album:i disc track file'); 432 my $perfile_table=Gtk2::Table->new( scalar(@$IDs), 10, FALSE); 433 $self->{perfile_table}=$perfile_table; 434 my $row=0; 435 $self->add_column('track'); 436 $self->add_column('title'); 437 438 my $lastcol=1; #for the filename column 439 my $BSelFields=Gtk2::Button->new(_"Select fields"); 440 { my $menu=Gtk2::Menu->new; 441 my $menu_cb=sub {$self->add_column($_[1])}; 442 for my $f ( Songs::EditFields('per_id') ) 443 { my $item=Gtk2::CheckMenuItem->new_with_label( Songs::FieldName($f) ); 444 $item->set_active(1) if $self->{'pfcheck_'.$f}; 445 $item->signal_connect(activate => $menu_cb,$f); 446 $menu->append($item); 447 $lastcol++; 448 } 449 #$menu->append(Gtk2::SeparatorMenuItem->new); 450 #my $item=Gtk2::CheckMenuItem->new(_"Select files"); 451 #$item->signal_connect(activate => sub { $self->add_selectfile_column }); 452 #$menu->append($item); 453 $BSelFields->signal_connect( button_press_event => sub 454 { ::PopupMenu($menu,event=>$_[1]); 455 }); 456 #$self->pack_start($menubar, FALSE, FALSE, 2); 457 #$perfile_table->attach($menubar,7,8,0,1,'fill','shrink',1,1); 458 } 459 460 #add filename column 461 $perfile_table->attach( Gtk2::Label->new(Songs::FieldName('file')) ,$lastcol,$lastcol+1,$row,$row+1,'fill','shrink',1,1); 462 for my $ID (@$IDs) 463 { $row++; 464 my $label=Gtk2::Label->new( Songs::Display($ID,'file') ); 465 $label->set_selectable(TRUE); 466 $label->set_alignment(0,0.5); #left-aligned 467 $perfile_table->attach($label,$lastcol,$lastcol+1,$row,$row+1,'fill','shrink',1,1); #filename 468 } 469 470 my $Btools=Gtk2::Button->new(_"tools"); 471 { my $menu=Gtk2::Menu->new; 472 my $menu_cb=sub {$self->tool($_[1])}; 473 for my $ref (@Tools) #currently only able to transform all entrys with the for_all function 474 { my $item=Gtk2::MenuItem->new($ref->{label}); 475 $item->signal_connect(activate => $menu_cb,$ref->{for_all}); 476 $menu->append($item) if $ref->{for_all}; 477 } 478 $Btools->signal_connect( button_press_event => sub 479 { ::PopupMenu($menu,event=>$_[1]); 480 }); 481 } 482 483 my $BClear=::NewIconButton('gtk-clear',undef, 484 sub { my $self=::find_ancestor($_[0],__PACKAGE__); $self->tool(sub {''}) }, 485 undef,_"Clear selected fields"); 486 487 my $sw = Gtk2::ScrolledWindow->new; 488 $sw->set_shadow_type('none'); 489 $sw->set_policy('automatic', 'automatic'); 490 $sw->add_with_viewport($perfile_table); 491 492 # expander to hide/show the per-file part 493 my $exp_label=Gtk2::Label->new_with_format("<b>%s</b>",_"Per-song values"); 494 my $expander=Gtk2::Expander->new; 495 $expander->set_expanded(TRUE); 496 $expander->set_label_widget($exp_label); 497 $expander->signal_connect(activate=>sub { my $on= !$_[0]->get_expanded; $_->set_visible($on) for $sw,$BSelFields; }); 498 499 $self->{vpaned}->pack2( ::Vpack('compact',[$expander,$BSelFields],'_',$sw), TRUE,FALSE); 500 my $vsizegroup=Gtk2::SizeGroup->new('vertical'); 501 $vsizegroup->add_widget($_) for $exp_label,$BSelFields; # so that they are aligned 502 $sw->set_size_request(-1,$exp_label->size_request->height); # so that the expander is always visible 503 504 my $store= Gtk2::ListStore->new('Glib::String','Glib::Scalar'); 505 $self->{autofill_combo}= my $Bautofill=Gtk2::ComboBox->new($store); 506 my $renderer=Gtk2::CellRendererText->new; 507 $Bautofill->pack_start($renderer,::TRUE); 508 $Bautofill->add_attribute($renderer, markup => 0); 509 $self->autofill_check; 510 $Bautofill->signal_connect(changed => \&autofill_cb); 511 ::Watch( $self, AutofillFormats => \&autofill_check); 512 513 my $checkOBlank=Gtk2::CheckButton->new(_"Auto fill only blank fields"); 514 $self->{AFOBlank}=$checkOBlank; 515 my $hbox=Gtk2::HBox->new; 516 $hbox->pack_start($_, FALSE, FALSE, 0) for Gtk2::VSeparator->new,$Bautofill,$BClear,$checkOBlank,$Btools, 517 $self->pack_start($hbox, FALSE, FALSE, 4); 518} 519 520sub add_column 521{ my ($self,$field)=@_; 522 if ($self->{'pfcheck_'.$field}) #if already created -> toggle show/hide 523 { my @w=( $self->{'pfcheck_'.$field}, @{ $self->{pf_widgets}{$field} } ); 524 my $show= !$w[0]->visible; 525 $_->set_visible($show) for @w; 526 return; 527 } 528 my $table=$self->{perfile_table}; 529 my $col=++$table->{col}; 530 my $row=0; 531 my $check=Gtk2::CheckButton->new( Songs::FieldName($field) ); 532 my @entries; 533 $self->{'pfcheck_'.$field}=$check; 534 $self->{pf_widgets}{$field}=\@entries; 535 for my $ID ( @{$self->{IDs}} ) 536 { $row++; 537 my $widget=Songs::EditWidget($field,'per_id',$ID); 538 next unless $widget; 539 $widget->set_sensitive(FALSE); 540 $widget->signal_connect(focus_in_event=> \&scroll_to_entry); 541 my $p= $widget->{noexpand} ? 'fill' : ['fill','expand']; 542 $table->attach($widget,$col,$col+1,$row,$row+1,$p,'shrink',1,1); 543 $widget->show_all; 544 push @entries,$widget; 545 } 546 $check->signal_connect( toggled => sub 547 { my $active=$_[0]->get_active; 548 $_->set_sensitive($active) for @entries; 549 }); 550 551 # add auto-increment/auto-complete button to track/disc/year columns 552 if ($field eq 'track' || $field eq 'disc' || $field eq 'year') 553 { #$_->set_alignment(1) for @entries; 554 my ($increment,$tip)= $field eq 'track' ? (1,_"Auto-increment track numbers") : (0,_"Copy missing values from previous line"); 555 my $autosub=sub 556 { my $i= $field ne 'year' ? 1 : 0; 557 for my $e (@entries) 558 { my $here=$e->get_text; 559 if ($here && $here=~m/^\d+$/) { $i=$here; } 560 elsif ($i>0) { $e->set_text($i) } 561 $i++ if $increment; 562 } 563 }; 564 my $button=::NewIconButton('gtk-go-down',undef,$autosub,'none',$tip); 565 $button->set_border_width(0); 566 $button->set_size_request(); 567 $check->signal_connect( toggled => sub { $button->set_sensitive($_[0]->get_active) }); 568 $button->set_sensitive(FALSE); 569 my $hbox=Gtk2::HBox->new(0,0); 570 $hbox->pack_start($_,0,0,0) for $check,$button; 571 $check=$hbox; 572 #$check= ::Hpack($check,$button); 573 } 574 $check->show_all; 575 $table->attach($check,$col,$col+1,0,1,'fill','shrink',1,1); 576} 577sub add_selectfile_column 578{ my $self=$_[0]; 579 if (my $l=$self->{'filetoggles'}) #if already created -> toggle show/hide 580 { my $show= !$l->[0]->visible; 581 $_->set_visible($show) for @$l; 582 return; 583 } 584 my @toggles; 585 $self->{'filetoggles'}=\@toggles; 586 my $table=$self->{perfile_table}; 587 my $row=0; my $col=0; my $i=0; 588 for my $ID ( @{$self->{IDs}} ) 589 { $row++; 590 my $check=Gtk2::CheckButton->new; 591 $check->set_active(1); 592 $check->signal_connect( toggled => sub { my ($check,$i)=@_; my $self=::find_ancestor($check,__PACKAGE__); my $active=$check->get_active; $self->{pf_widgets}{$_}[$i]->set_sensitive($active) for keys %{ $self->{pf_widgets} } },$i); 593 #$widget->signal_connect(focus_in_event=> \&scroll_to_entry); 594 $table->attach($check,$col,$col+1,$row,$row+1,'fill','shrink',1,1); 595 $check->show_all; 596 push @toggles,$check; 597 $i++; 598 } 599} 600 601sub scroll_to_entry 602{ my $ent=$_[0]; 603 if (my $sw=::find_ancestor($ent,'Gtk2::Viewport')) 604 { my ($x,$y,$w,$h)= $ent->allocation->values; 605 $sw->get_hadjustment->clamp_page($x,$x+$w); 606 $sw->get_vadjustment->clamp_page($y,$y+$h); 607 }; 608 0; 609} 610 611sub autofill_check 612{ my $self=shift; 613 my $combo=$self->{autofill_combo}; 614 my $store=$combo->get_model; 615 $store->clear; 616 $store->set( $store->append, 0, ::PangoEsc(_"Auto fill based on filenames ...")); 617 my @files= map ::filename_to_utf8displayname($_), Songs::Map('barefilename',$self->{IDs}); 618 autofill_user_formats(); 619 for my $ref (@FORMATS_user,@FORMATS) 620 { my ($format,$re)=@$ref; 621 next if @files/2 > (grep m/$re/, @files); # ignore patterns that match less than half of the filenames 622 my $formatname= '<b>'.::PangoEsc($format).'</b>'; 623 $formatname= GMB::Edit::Autofill_formats::make_format_name($formatname,"</b><i>%s</i><b>"); 624 $store->set($store->append, 0,$formatname, 1, $ref); 625 } 626 $store->set( $store->append, 0, ::PangoEsc(_"Edit auto-fill formats ..."), 1, \&GMB::Edit::Autofill_formats::new); 627 $combo->set_active(0); 628} 629 630sub autofill_user_formats 631{ my $h= $::Options{filename2tags_formats}; 632 return if !$h || @FORMATS_user; 633 for my $format (sort keys %$h) 634 { my $re= $h->{$format}; 635 if (!defined $re) 636 { $re= GMB::Edit::Autofill_formats::make_default_re($format); 637 } 638 my $qr=eval { qr/$re/i; }; 639 if ($@) { warn "Error compiling regular expression for '$format' : $re\n$@"; next} 640 push @FORMATS_user, [$format,$qr]; 641 } 642} 643 644sub autofill_cb 645{ my $combo=shift; 646 my $self=::find_ancestor($combo,__PACKAGE__); 647 my $iter=$combo->get_active_iter; 648 return unless $iter; 649 my $ref=$combo->get_model->get($iter,1); 650 return unless $ref; 651 if (ref $ref eq 'CODE') { $ref->($self); return; } # for edition of filename formats 652 my ($format,$pattern)=@$ref; 653 my @fields= GMB::Edit::Autofill_formats::find_fields($format); 654 $_ eq 'album_artist' and $_='album_artist_raw' for @fields; #FIXME find a more generic way to do that 655 my $OBlank=$self->{AFOBlank}->get_active; 656 my @vals; 657 for my $ID (@{$self->{IDs}}) 658 { my $file= Songs::Display($ID,'barefilename'); 659 my @v=($file=~m/$pattern/); 660 s/_/ /g, s/^\s+//, s/\s+$// for @v; 661 @v=('')x scalar(@fields) unless @v; 662 my $n=0; 663 push @{$vals[$n++]},$_ for @v; 664 } 665 for my $f (@fields) 666 { my $varray=shift @vals; 667 my %h; $h{$_}=undef for @$varray; delete $h{''}; 668 if ( (keys %h)==1 ) 669 { my $entry=$self->{widgets}{$f}; 670 if ($entry && $entry->is_sensitive) 671 { next if $OBlank && !($entry->can('is_blank') ? $entry->is_blank : $entry->get_text eq ''); 672 $entry->set_text(keys %h); 673 next 674 } 675 } 676 my $entries= $self->{pf_widgets}{$f}; 677 next unless $entries; 678 for my $e (@$entries) 679 { my $v=shift @$varray; 680 next if $OBlank && !($e->can('is_blank') ? $e->is_blank : $e->get_text eq ''); 681 $e->set_text($v) if $e->is_sensitive && $v ne ''; 682 } 683 } 684} 685 686sub tool 687{ my ($self,$sub)=@_; 688 #my $OBlank=$self->{AFOBlank}->get_active; 689 #$OBlank=0 if $ignoreOB; 690 my $IDs=$self->{IDs}; 691 for my $wdgt ( values %{$self->{widgets}}, map @$_, values %{$self->{pf_widgets}} ) 692 { next unless $wdgt->is_sensitive && $wdgt->can('tool'); 693 $wdgt->tool($sub); 694 } 695 #for my $entries (values %{$self->{pf_widgets}}) 696 #{ next unless $entries->[0]->is_sensitive && $entries->[0]->can('tool'); 697 # for my $e (@$entries) 698 # { $wdgt->tool($sub); 699 # } 700 #} 701} 702 703sub save 704{ my ($self,$finishsub)=@_; 705 my $IDs=$self->{IDs}; 706 my (%default,@modif); 707 while ( my ($f,$wdgt)=each %{$self->{widgets}} ) 708 { next unless $wdgt->is_sensitive; 709 if ($wdgt->can('return_setunset')) 710 { my ($set,$unset)=$wdgt->return_setunset; 711 push @modif,"+$f",$set if @$set; 712 push @modif,"-$f",$unset if @$unset; 713 } 714 else 715 { my $v=$wdgt->get_text; 716 $default{$f}=$v; 717 $f='@'.$f if ref $v; 718 push @modif, $f,$v; 719 } 720 } 721 while ( my ($f,$wdgt)=each %{$self->{pf_widgets}} ) 722 { next unless $wdgt->[0]->is_sensitive; 723 my @vals; 724 for my $ID (@$IDs) 725 { my $v=(shift @$wdgt)->get_text; 726 $v=$default{$f} if $v eq '' && exists $default{$f}; 727 push @vals,$v; 728 } 729 push @modif, '@'.$f,\@vals; 730 } 731 unless (@modif) { $finishsub->(); return} 732 733 $self->set_sensitive(FALSE); 734 my $progressbar = Gtk2::ProgressBar->new; 735 $self->pack_start($progressbar, FALSE, TRUE, 0); 736 $progressbar->show_all; 737 Songs::Set($IDs,\@modif, progress=>$progressbar, callback_finish=>$finishsub, window=> $self->get_toplevel); 738} 739 740package GMB::Edit::Autofill_formats; 741use base 'Gtk2::Dialog'; 742our $Instance; 743 744our %Override; 745INIT 746{ %Override= ('%A'=> '$album_artist_raw'); 747} 748 749sub new 750{ my $ID= $_[0]{IDs}[0]; 751 if ($Instance) { $Instance->force_present; $Instance->{ID}=$ID; $Instance->preview_update; return }; 752 my $self = Gtk2::Dialog->new ("Custom auto-fill filename formats", undef, [], 'gtk-close' => 'none'); 753 $Instance=bless $self,__PACKAGE__; 754 ::SetWSize($self,'AutofillFormats'); 755 $self->set_border_width(4); 756 $self->{ID}=$ID; 757 $self->{store}=my $store= Gtk2::ListStore->new('Glib::String','Glib::String'); 758 $self->{treeview}=my $treeview=Gtk2::TreeView->new($store); 759 $treeview->append_column( Gtk2::TreeViewColumn->new_with_attributes(_"Custom formats", Gtk2::CellRendererText->new, text => 0 )); 760 #$treeview->set_headers_visible(::FALSE); 761 $treeview->signal_connect(cursor_changed=> \&cursor_changed_cb); 762 763 my $label_format=Gtk2::Label->new(_"Filename format :"); 764 my $label_re= Gtk2::Label->new(_"Regular expression :"); 765 $self->{entry_format}= my $entry_format=Gtk2::Entry->new; 766 $self->{entry_re}= my $entry_re= Gtk2::Entry->new; 767 $self->{check_re}= my $check_re= Gtk2::CheckButton->new(_"Use default regular expression"); 768 $self->{error}= my $error= Gtk2::Label->new; 769 $self->{preview}= my $preview= Gtk2::Label->new; 770 $self->{remove_button}= my $button_del= ::NewIconButton('gtk-remove',_"Remove"); 771 $self->{add_button}= my $button_add= ::NewIconButton('gtk-save',_"Save"); 772 my $button_new= ::NewIconButton('gtk-new', _"New"); 773 $button_del->signal_connect(clicked=>\&button_cb,'remove'); 774 $button_add->signal_connect(clicked=>\&button_cb,'save'); 775 $button_new->signal_connect(clicked=>\&button_cb,'new'); 776 $preview->set_alignment(0,.5); 777 my $sg=Gtk2::SizeGroup->new('horizontal'); 778 $sg->add_widget($_) for $label_format,$label_re; 779 my $bbox= Gtk2::HButtonBox->new; 780 $bbox->add($_) for $button_del, $button_add, $button_new; 781 my $sw= ::new_scrolledwindow($treeview,'etched-in'); 782 $sw->set_size_request(150,-1); #give the list a minimum width 783 my $table= ::MakeReplaceTable('taAlCyndgL', A=>Songs::FieldName('album_artist_raw')); #AutoFillFields 784 my $hbox= ::Vpack([$label_format,'_',$entry_format],$table,$check_re,[$label_re,'_',$entry_re],$error,$preview,'-',$bbox); 785 my $hpaned= Gtk2::HPaned->new; 786 $hpaned->pack1($sw,1,1); 787 $hpaned->pack2($hbox,1,0); 788 $self->vbox->add($hpaned); 789 790 ::set_drag($preview, dest => [::DRAG_ID,\&song_dropped]); 791 $entry_format->signal_connect(changed=> \&entry_changed); 792 $entry_re->signal_connect(changed=> \&preview_update); 793 $check_re->signal_connect(toggled=> sub { $entry_re->set_sensitive(!$_[0]->get_active); entry_changed($_[0]); }); 794 $check_re->set_active(1); 795 $entry_re->set_sensitive(0); 796 $self->entry_changed; 797 $self->fill_store; 798 $self->show_all; 799 $self->signal_connect( response => sub { $_[0]->destroy; $Instance=undef; }); 800} 801 802sub song_dropped 803{ my ($preview,$type,$ID)=@_; 804 my $self= ::find_ancestor($preview,__PACKAGE__); 805 $self->{ID}=$ID; 806 $self->preview_update; 807} 808 809sub entry_changed 810{ my $self= ::find_ancestor($_[0],__PACKAGE__); 811 my $text= $self->{entry_format}->get_text; 812 my $match= exists $::Options{filename2tags_formats}{$text}; 813 $self->{remove_button}->set_sensitive($match); 814 $self->{busy}=1; 815 my $selection= $self->{treeview}->get_selection; 816 $selection->unselect_all; 817 if ($match) 818 { my $store=$self->{store}; 819 my $iter=$store->get_iter_first; 820 while ($iter) 821 { if ($store->get($iter,1) eq $text) 822 { $selection->select_iter($iter); 823 last; 824 } 825 $iter=$store->iter_next($iter); 826 } 827 } 828 $self->{add_button}->set_sensitive( length $text ); 829 if ($self->{check_re}->get_active) 830 { $self->{entry_re}->set_text( make_default_re($text) ); 831 } 832 $self->{busy}=0; 833 $self->preview_update; 834} 835 836sub preview_update 837{ my $self= ::find_ancestor($_[0],__PACKAGE__); 838 return if $self->{busy}; 839 my $re=$self->{entry_re}->get_text; 840 my $qr=eval { qr/$re/i; }; 841 if ($@) 842 { $self->{error}->show; 843 $self->{error}->set_markup_with_format("<i><b>%s</b></i>",_"Invalid regular expression"); 844 $self->{preview}->set_text(''); 845 return; 846 } 847 my $format=$self->{entry_format}->get_text; 848 my @fields= map Songs::FieldName($_), find_fields($format); 849 my $ID=$self->{ID}; 850 my $file= Songs::Display($ID,'barefilename'); 851 my @text=(_"Example :", Songs::FieldName('file'), $file); 852 my $preview= "%s\n<i>%s</i> : <small>%s</small>\n\n"; 853 my @v; 854 @v= ($file=~m/$qr/) if $re; 855 if (@v || !$re) { $self->{error}->hide; $self->{error}->set_text(''); } 856 else 857 { $self->{error}->show; 858 $self->{error}->set_markup_with_format("<i><b>%s</b></i>",_"Regular expression didn't match"); 859 } 860 s/_/ /g, s/^\s+//, s/\s+$// for @v; 861 for my $i (sort { $fields[$a] cmp $fields[$b] } 0..$#fields) 862 { my $v= $v[$i]; 863 $v='' unless defined $v; 864 push @text, $fields[$i],$v; 865 $preview.= "<i>%s</i> : %s\n"; 866 } 867 $self->{preview}->set_markup_with_format($preview,@text); 868} 869 870sub button_cb 871{ my ($button,$action)=@_; 872 my $self= ::find_ancestor($button,__PACKAGE__); 873 my $formats= $::Options{filename2tags_formats}; 874 my $format= $self->{entry_format}->get_text; 875 if ($action eq 'remove') 876 { delete $formats->{$format}; 877 } 878 if ($action eq 'new' || $action eq 'remove') 879 { $self->{check_re}->set_active(1); 880 $self->{entry_format}->set_text(''); 881 } 882 else 883 { $formats->{$format}= $self->{check_re}->get_active ? undef : $self->{entry_re}->get_text; 884 } 885 return if $action eq 'new'; 886 $self->fill_store; 887 @FORMATS_user=(); 888 ::HasChanged('AutofillFormats'); 889} 890 891sub fill_store 892{ my $self=shift; 893 my $store=$self->{store}; 894 $store->clear; 895 my $formats= $::Options{filename2tags_formats} ||= {}; 896 for my $format (sort keys %$formats) 897 { my $formatname= make_format_name($format); 898 $store->set($store->append, 0,$formatname, 1,$format); 899 } 900 $self->entry_changed; 901} 902 903sub make_format_name 904{ my ($format,$markup)=@_; 905 $format=~s#(\$\w+|%[a-zA-Z]|\$\{\w+\})|([%\$])\2# 906 $2 || do { my $f= $::ReplaceFields{ $Override{$1}||$1 }; 907 $f=undef if $f && $Songs::Def{$f}{flags}!~m/e/; 908 $f&&= Songs::FieldName($f); 909 $f&&= ::MarkupFormat($markup,$f) if $markup; 910 $f || $1 911 }#ge; 912 return $format; 913} 914sub find_fields 915{ my $format=shift; 916 my @fields= map $::ReplaceFields{$Override{$_}||$_}, grep defined, $format=~m/ %% | \$\$ | ( \$\w+ | %[a-zA-Z] | \$\{\w+\} ) /gx; 917 @fields= grep defined && $Songs::Def{$_}{flags}=~m/e/, @fields; 918 return @fields; 919} 920sub make_default_re 921{ my $re=shift; 922 $re=~s#(\$\w+|%[a-zA-Z]|\$\{\w+\})|%(%)|\$(\$)|(%?[-,;\w ]+)|(.)# 923 $1 ? Songs::ReplaceFields_to_re( $Override{$1}||$1 ) : 924 $2 ? $2 : $3 ? '\\'.$3 : defined $4 ? $4 : '\\'.$5 #ge; 925 return $re; 926} 927 928sub cursor_changed_cb 929{ my $treeview=shift; 930 my $self=::find_ancestor($treeview,__PACKAGE__); 931 return if $self->{busy}; 932 my $path=($treeview->get_cursor)[0]; 933 return unless $path; 934 my $store=$treeview->get_model; 935 my $format= $store->get( $store->get_iter($path), 1); 936 my $re= $::Options{filename2tags_formats}{$format}; 937 $self->{entry_format}->set_text($format); 938 $self->{check_re}->set_active( !defined $re ); 939 $self->{entry_re}->set_text($re) if defined $re; 940} 941 942 943package GMB::TagEdit::EntryString; 944use base 'Gtk2::Entry'; 945 946sub new 947{ my ($class,$field,$ID,$width,$completion) = @_; 948 my $self = bless Gtk2::Entry->new, $class; 949 #$self->{field}=$field; 950 my $val=Songs::Get($ID,$field); 951 $self->set_text($val); 952 GMB::ListStore::Field::setcompletion($self,$field) if $completion; 953 if ($width) { $self->set_width_chars($width); $self->{noexpand}=1; } 954 return $self; 955} 956 957sub tool 958{ my ($self,$sub)=@_; 959 my $val= $sub->($self->get_text); 960 $self->set_text($val) if defined $val; 961} 962 963package GMB::TagEdit::EntryText; 964use base 'Gtk2::Box'; 965 966sub new 967{ my ($class,$field,$IDs) = @_; 968 my $self = bless Gtk2::VBox->new, $class; 969 my $textview= $self->{textview}= Gtk2::TextView->new; 970 $textview->set_size_request(100,($textview->create_pango_layout("X")->get_pixel_size)[1]*4); #request 4 lines of height 971 my $sw= ::new_scrolledwindow($textview,'etched-in'); 972 $self->add($sw); 973 my $val; 974 if (ref $IDs) 975 { my $values= Songs::BuildHash($field,$IDs); 976 my @l=sort { $values->{$b} <=> $values->{$a} } keys %$values; #sort values by their frequency 977 $val=$l[0]; 978 $self->{IDs}=$IDs; 979 $self->{field}=$field; 980 $self->{append}=my $append=Gtk2::CheckButton->new(_"Append (only if not already present)"); 981 $self->pack_end($append,0,0,0); 982 } 983 else { $val=Songs::Get($IDs,$field); } 984 $self->set_text($val); 985 return $self; 986} 987sub set_text 988{ my $self=shift; 989 $self->{textview}->get_buffer->set_text(shift); 990} 991sub get_text 992{ my $self=shift; 993 my $buffer=$self->{textview}->get_buffer; 994 my $text=$buffer->get_text( $buffer->get_bounds, 1); 995 if ($self->{append} && $self->{append}->get_active) #append 996 { my @orig= Songs::Map($self->{field},$self->{IDs}); 997 for my $orig (@orig) 998 { next if $text eq ''; 999 if ($orig eq '') { $orig=$text; } 1000 else 1001 { next if index("$orig\n","$text\n")!=-1; #don't append if the line(s) already exists 1002 $orig.="\n".$text; 1003 } 1004 } 1005 return \@orig; 1006 } 1007 return $text; 1008} 1009sub tool 1010{ &GMB::TagEdit::EntryString::tool; 1011} 1012 1013package GMB::TagEdit::EntryNumber; 1014use base 'Gtk2::SpinButton'; 1015 1016sub new 1017{ my ($class,$field,$IDs,%opt) = @_; #possible options in %opt : signed digits min max mode 1018 my $mode=$opt{mode}||''; 1019 my $max= $opt{max} || 10000000; 1020 my $min= $opt{min} || ($opt{signed} ? -$max : 0); 1021 my $digits= $opt{digits} || 0; 1022 my $adj=Gtk2::Adjustment->new(0,$min,$max,1,10,0); 1023 my $self = bless Gtk2::SpinButton->new($adj,10,$digits), $class; 1024 $self->{noexpand}=1; 1025 #$self->{field}=$field; 1026 my $val; 1027 if (ref $IDs) 1028 { my $values= Songs::BuildHash($field,$IDs); 1029 my @l=sort { $values->{$b} <=> $values->{$a} } keys %$values; #sort values by their frequency 1030 $val=$l[0]; #take the most common value 1031 } 1032 else { $val=Songs::Get($IDs,$field); } 1033 1034 if ($mode) 1035 { if ($mode eq 'nozero') # 0 is displayed as "" 1036 { $self->signal_connect(output=> \&output_nozero); 1037 } 1038 elsif ($mode eq 'allow_empty') # non-numeric values are replaced with "" which is treated as different than 0 1039 { $self->signal_connect(input => sub { my $v=Gtk2::Entry::get_text($_[0]); $_[0]{null}= $v!~/\d/; return 0}); 1040 $self->signal_connect(output=> sub { my $v=$_[0]->get_value; $_[0]{null}=0 if $v; return 0 if !$_[0]{null}; Gtk2::Entry::set_text($_[0],''); return 1; }); 1041 } 1042 elsif ($mode eq 'year') 1043 { $self->set_wrap(1); 1044 # set to current year when increasing or decreasing value from 0 1045 $self->signal_connect(value_changed=>sub 1046 { my $v=$_[0]->get_value; 1047 $_[0]->set_value( (localtime)[5]+1900 ) if $v==1 || $v>=$max; 1048 }); 1049 $self->signal_connect(output=> \&output_nozero); 1050 } 1051 } 1052 1053 if ($mode eq 'allow_empty' && !length $val) { $self->{null}=1; $self->set_text(''); } 1054 else { $self->set_value($val); } 1055 1056 return $self; 1057} 1058sub get_text 1059{ $_[0]{null} ? '' : $_[0]->get_value; 1060} 1061sub set_text 1062{ my $v=$_[1]; 1063 $v=0 unless $v=~m/^\d+$/; 1064 $_[0]->set_value($v); 1065} 1066sub is_blank 1067{ my $self=shift; 1068 return ! $self->get_value; 1069} 1070sub tool 1071{ &GMB::TagEdit::EntryString::tool; 1072} 1073 1074sub output_nozero 1075{ my $v=$_[0]->get_value; 1076 return 0 if $v; 1077 Gtk2::Entry::set_text($_[0],''); 1078 return 1; 1079} 1080 1081package GMB::TagEdit::EntryBoolean; 1082use base 'Gtk2::CheckButton'; 1083 1084sub new 1085{ my ($class,$field,$IDs) = @_; 1086 my $self = bless Gtk2::CheckButton->new, $class; 1087 $self->{noexpand}=1; 1088 #$self->{field}=$field; 1089 my $val; 1090 if (ref $IDs) 1091 { my $values= Songs::BuildHash($field,$IDs); 1092 my @l=sort { $values->{$b} <=> $values->{$a} } keys %$values; #sort values by their frequency 1093 $val=$l[0]; #take the most common value 1094 } 1095 else { $val=Songs::Get($IDs,$field); } 1096 $self->set_active($val); 1097 return $self; 1098} 1099sub get_text 1100{ $_[0]->get_active; 1101} 1102 1103package GMB::TagEdit::Combo; 1104use base 'Gtk2::Box'; 1105 1106sub new 1107{ my ($class,$field,$IDs,$listall) = @_; 1108 my $self= bless Gtk2::HBox->new, $class; 1109 my $combo= Gtk2::ComboBoxEntry->new_text; 1110 $self->add($combo); 1111 $self->{combo}=$combo; 1112 my $entry=$self->{entry}=$combo->child; 1113 #$self->{field}=$field; 1114 1115 my $values= Songs::BuildHash($field,$IDs); 1116 my @l=sort { $values->{$b} <=> $values->{$a} } keys %$values; #sort values by their frequency 1117 my $first=$l[0]; 1118 @l= @{ Songs::Gid_to_Get($field,\@l) } if Songs::Field_property($field,'gid_to_get'); 1119 if ($listall) 1120 { my $cb=sub 1121 { ::PopupAA(Songs::MainField($field),noalt=>1, cb=> sub { $entry->set_text( Songs::Gid_to_Get($field,$_[0]{key}) ); }); 1122 }; 1123 my $pick= ::NewIconButton('gtk-index',undef,$cb,'none',_"Pick an existing one"); 1124 $self->pack_end($pick,0,0,0); 1125 } 1126 $combo->append_text($_) for @l; 1127 $entry->set_text($l[0]) if $values->{$first} > @$IDs/3; 1128 1129 GMB::ListStore::Field::setcompletion($entry,$field) if $listall; 1130 1131 return $self; 1132} 1133 1134sub set_text 1135{ $_[0]{entry}->set_text($_[1]); 1136} 1137sub get_text 1138{ $_[0]{entry}->get_text; 1139} 1140sub tool 1141{ &GMB::TagEdit::EntryString::tool; 1142} 1143 1144 1145package GMB::TagEdit::EntryRating; 1146use base 'Gtk2::Box'; 1147 1148sub new 1149{ my ($class,$field,$IDs) = @_; 1150 my $self = bless Gtk2::HBox->new, $class; 1151 #$self->{field}=$field; 1152 1153 my $init; 1154 if (ref $IDs) 1155 { my $h= Songs::BuildHash($field,$IDs); 1156 $init=(sort { $h->{$b} <=> $h->{$a} } keys %$h)[0]; 1157 } 1158 else { $init=Songs::Get($IDs,$field); } 1159 1160 my $adj=Gtk2::Adjustment->new(0,0,100,10,20,0); 1161 my $spin=Gtk2::SpinButton->new($adj,10,0); 1162 my $check=Gtk2::CheckButton->new(_"use default"); 1163 my $stars=Stars->new($field,$init,\&update_cb); 1164 1165 $self->pack_start($_,0,0,0) for $stars,$spin,$check; 1166 $self->{stars}=$stars; 1167 $self->{check}=$check; 1168 $self->{adj}=$adj; 1169 1170 $self->update_cb($init); 1171 #$self->{modif}=0; 1172 $adj->signal_connect(value_changed => sub{ $self->update_cb($_[0]->get_value) }); 1173 $check->signal_connect(toggled => sub{ update_cb($_[0], ($_[0]->get_active ? '' : $::Options{DefaultRating}) ) }); 1174 1175 return $self; 1176} 1177 1178sub update_cb 1179{ my ($widget,$v)=@_; 1180 my $self=::find_ancestor($widget,__PACKAGE__); 1181 return if $self->{busy}; 1182 $self->{busy}=1; 1183 $v='' unless defined $v && $v ne '' && $v!=255; 1184 #$self->{modif}=1; 1185 $self->{value}=$v; 1186 $self->{check}->set_active($v eq ''); 1187 $self->{stars}->set($v); 1188 $v=$::Options{DefaultRating} if $v eq ''; 1189 $self->{adj}->set_value($v); 1190 $self->{busy}=0; 1191} 1192 1193sub get_text 1194{ $_[0]->{value}; 1195} 1196sub is_blank 1197{ my $v=$_[0]->{value}; 1198 $v eq '' || $v==255; 1199} 1200 1201package GMB::TagEdit::FlagList; 1202use base 'Gtk2::Box'; 1203 1204sub new 1205{ my ($class,$field,$ID) = @_; 1206 my $self = bless Gtk2::HBox->new(0,0), $class; 1207 $self->{field}=$field; 1208 $self->{ID}=$ID; 1209 my $button= Gtk2::Button->new; 1210 my $add= ::NewIconButton('gtk-add'); 1211 my $entry= $self->{entry}= Gtk2::Entry->new; 1212 my $label=$self->{label}=Gtk2::Label->new; 1213 $label->set_ellipsize('end'); 1214 $entry->set_width_chars(12); 1215 $button->add($label); 1216 $self->pack_start($button,1,1,0); 1217 $self->pack_start($entry,0,0,0); 1218 $self->pack_start($add,0,0,0); 1219 $add->signal_connect( button_press_event => sub { add_entry_text_cb($_[0]); $_[0]->grab_focus;1; } ); 1220 $add->signal_connect( clicked => \&add_entry_text_cb ); 1221 $button->signal_connect( clicked => \&popup_menu_cb); 1222 $button->signal_connect( button_press_event => sub { popup_menu_cb($_[0]); $_[0]->grab_focus;1; } ); 1223 $entry->signal_connect( activate => \&add_entry_text_cb ); 1224 GMB::ListStore::Field::setcompletion($entry,$field); 1225 1226 $self->{selected}{$_}=1 for Songs::Get_list($ID,$field); 1227 delete $self->{selected}{''}; 1228 $self->update; 1229 return $self; 1230} 1231 1232sub add_entry_text_cb 1233{ my $widget=shift; 1234 my $self=::find_ancestor($widget,__PACKAGE__); 1235 my $entry=$self->{entry}; 1236 my $text=$entry->get_text; 1237 if ($text eq '') { $self->popup_add_menu($widget); return } 1238 # split $text ? 1239 $self->{selected}{$text}=1; 1240 $entry->set_text(''); 1241 $self->update; 1242} 1243 1244sub popup_add_menu 1245{ my ($self,$widget)=@_; 1246 my $cb= sub { $self->{selected}{ $_[1] }= 1; $self->update; }; 1247 my $menu=::MakeFlagMenu($self->{field},$cb); 1248 ::PopupMenu($menu, posfunction=>sub {::windowpos($_[0],$widget)} ); 1249} 1250 1251sub popup_menu_cb 1252{ my $widget=shift; 1253 my $self=::find_ancestor($widget,__PACKAGE__); 1254 my $menu=Gtk2::Menu->new; 1255 my $cb= sub { $self->{selected}{ $_[1] }^=1; $self->update; }; 1256 my @keys= ::superlc_sort(keys %{$self->{selected}}); 1257 return unless @keys; 1258 for my $key (@keys) 1259 { my $item=Gtk2::CheckMenuItem->new_with_label($key); 1260 $item->set_active(1) if $self->{selected}{$key}; 1261 $item->signal_connect(toggled => $cb,$key); 1262 $menu->append($item); 1263 } 1264 ::PopupMenu($menu); 1265} 1266 1267sub update 1268{ my $self=$_[0]; 1269 my $h=$self->{selected}; 1270 my $text=join '<b>, </b>', map ::PangoEsc($_), ::superlc_sort(grep $h->{$_}, keys %$h); 1271 #$text= ::MarkupFormat("<i>- %s -</i>",_"None") if $text eq ''; 1272 $self->{label}->set_markup($text); 1273 $self->{label}->parent->set_tooltip_markup($text); 1274} 1275 1276sub get_text 1277{ my $self=shift; 1278 my $h=$self->{selected}; 1279 return [grep $h->{$_}, keys %$h]; 1280} 1281 1282sub is_blank 1283{ my $self=shift; 1284 my $list= $self->get_text; 1285 return !(@$list); 1286} 1287sub set_text # for setting from autofill-from-filename 1288{ my ($self,$val)=@_; 1289 my @vals= grep $_ ne '', split /\s*[;,]\s*/, $val; # currently split on ; or , 1290 my $selected= $self->{selected}; 1291 $selected->{$_}=0 for keys %$selected; #remove all 1292 $selected->{$_}=1 for @vals; 1293 $self->update; 1294} 1295 1296package GMB::TagEdit::EntryMassList; #for mass-editing fields with multiple values 1297use base 'Gtk2::Box'; 1298 1299sub new 1300{ my ($class,$field,$IDs) = @_; 1301 my $self = bless Gtk2::VBox->new(1,1), $class; 1302 $self->{field}=$field; 1303 my $sg= Gtk2::SizeGroup->new('horizontal'); 1304 my $entry= $self->{entry}= Gtk2::Entry->new; 1305 my $add= ::NewIconButton('gtk-add'); 1306 my $removeall= ::NewIconButton('gtk-clear', _"Remove all", \&clear); 1307 $add->signal_connect( button_press_event => sub { add_entry_text_cb($_[0]); $_[0]->grab_focus;1; } ); 1308 $add->signal_connect( clicked => \&add_entry_text_cb ); 1309 for my $ref (['toadd',1,_"Add"],['toremove',-1,_"Remove"]) 1310 { my ($key,$mode,$text)=@$ref; 1311 my $label=$self->{$key}=Gtk2::Label->new; 1312 $label->set_ellipsize('end'); 1313 $label->{mode}=$mode; 1314 my $button= Gtk2::Button->new; 1315 $button->add($label); 1316 $button->{mode}=$mode; 1317 $button->signal_connect( clicked => \&popup_menu_cb ); 1318 $button->signal_connect( button_press_event => sub { popup_menu_cb($_[0]); $_[0]->grab_focus;1; } ); 1319 my $sidelabel= Gtk2::Label->new($text); 1320 my $hbox= Gtk2::HBox->new(0,1); 1321 $hbox->pack_start($sidelabel,0,0,2); 1322 $hbox->pack_start($button,1,1,2); 1323 $hbox->pack_start($entry,0,0,0) if $mode>0; 1324 $hbox->pack_start($add,0,0,0) if $mode>0; 1325 $hbox->pack_start($removeall,0,0,0) if $mode<0; 1326 $self->pack_start($hbox,0,0,2); 1327 $sidelabel->set_alignment(0,.5); 1328 $sg->add_widget($sidelabel); 1329 } 1330 GMB::ListStore::Field::setcompletion($entry,$field); 1331 $entry->signal_connect(activate => \&add_entry_text_cb); 1332 my $valueshash= Songs::BuildHash($field,$IDs); 1333 my %selected; 1334 $selected{ Songs::Gid_to_Get($field,$_) }= $valueshash->{$_}==@$IDs ? 1 : 0 for keys %$valueshash; 1335 delete $selected{''}; 1336 $self->{selected}=\%selected; 1337 $self->{all}= [keys %selected]; #all values that are set for at least one song 1338 $self->update; 1339 return $self; 1340} 1341 1342sub update #update the text and tooltips of buttons 1343{ my $self=shift; 1344 for my $key (qw/toadd toremove/) 1345 { my $label= $self->{$key}; 1346 my $mode= $label->{mode}; # -1 or 1 1347 my $h= $self->{selected}; 1348 my $text=join '<b>, </b>', map ::PangoEsc($_), ::superlc_sort(grep $h->{$_}==$mode, keys %$h); 1349 #$text= ::MarkupFormat("<i>- %s -</i>",_"None") if $text eq ''; 1350 $label->set_markup($text); 1351 $label->parent->set_tooltip_markup($text); # set tooltip on button 1352 } 1353} 1354 1355sub add_entry_text_cb 1356{ my $widget=shift; 1357 my $self=::find_ancestor($widget,__PACKAGE__); 1358 my $entry=$self->{entry}; 1359 my $text=$entry->get_text; 1360 if ($text eq '') { $self->popup_add_menu($widget); return } 1361 # split $text ? 1362 $self->{selected}{$text}=1; 1363 $entry->set_text(''); 1364 $self->update; 1365} 1366 1367sub clear # set to -1 all values present in at least one song, set to 0 values not present 1368{ my $self=::find_ancestor($_[0],__PACKAGE__); 1369 my $h= $self->{selected}; 1370 $_=0 for values %$h; 1371 $h->{$_}=-1 for @{$self->{all}}; 1372 $self->update; 1373} 1374 1375sub popup_add_menu 1376{ my ($self,$widget)=@_; 1377 my $cb= sub { $self->{selected}{ $_[1] }= 1; $self->update; }; 1378 my $menu=::MakeFlagMenu($self->{field},$cb); 1379 ::PopupMenu($menu, posfunction=>sub {::windowpos($_[0],$widget)} ); 1380} 1381 1382sub popup_menu_cb 1383{ my $child=shift; 1384 my $mode=$child->{mode}; 1385 my $self=::find_ancestor($child,__PACKAGE__); 1386 my $h= $self->{selected}; 1387 my $menu=Gtk2::Menu->new; 1388 my $cb= sub { $self->{selected}{ $_[1] }= $_[0]->get_active ? $mode : 0; $self->update; }; 1389 my @keys= ::superlc_sort(keys %$h); 1390 return unless @keys; 1391 for my $key (@keys) 1392 { my $item=Gtk2::CheckMenuItem->new_with_label($key); 1393 $item->set_active(1) if $h->{$key}==$mode; 1394 $item->signal_connect(toggled => $cb,$key); 1395 $menu->append($item); 1396 } 1397 ::PopupMenu($menu); 1398 1; 1399} 1400 1401sub return_setunset 1402{ my $self=$_[0]; 1403 my (@set,@unset); 1404 my $h=$self->{selected}; 1405 for my $value (keys %$h) 1406 { my $mode=$h->{$value}; 1407 if ($mode>0) { push @set,$value } 1408 elsif ($mode<0) { push @unset,$value } 1409 } 1410 return \@set,\@unset; 1411} 1412 1413sub is_blank {1} 1414sub set_text # for setting from autofill-from-filename 1415{ my ($self,$val)=@_; 1416 my @vals= grep $_ ne '', split /\s*[;,]\s*/, $val; # currently split on ; or , 1417 my $selected= $self->{selected}; 1418 #$selected->{$_}=0 for keys %$selected; #remove all 1419 $selected->{$_}=1 for @vals; 1420 $self->update; 1421} 1422 1423package EditTagSimple; 1424use base 'Gtk2::Box'; 1425 1426use constant { TRUE => 1, FALSE => 0, }; 1427 1428sub new 1429{ my ($class,$ID) = @_; 1430 my $self = bless Gtk2::VBox->new, $class; 1431 $self->{ID}=$ID; 1432 1433 my $labelfile = Gtk2::Label->new; 1434 $labelfile->set_markup( ::ReplaceFieldsAndEsc($ID,'<small>%u</small>') ); 1435 $labelfile->set_selectable(TRUE); 1436 $labelfile->set_line_wrap(TRUE); 1437 1438 my $sw=Gtk2::ScrolledWindow->new; 1439 $sw->set_shadow_type('none'); 1440 $sw->set_policy('never', 'automatic'); 1441 1442 my $table=Gtk2::Table->new (6, 2, FALSE); 1443 $sw->add_with_viewport($table); 1444 $self->{table}=$table; 1445 $self->fill; 1446 1447 $self->pack_start($labelfile,FALSE,FALSE,1); 1448 $self->pack_start($sw, TRUE, TRUE, 2); 1449 1450 return $self; 1451} 1452 1453sub fill 1454{ my $self=$_[0]; 1455 my $table=$self->{table}; 1456 my $ID=$self->{ID}; 1457 my $row1=my $row2=0; 1458 for my $field ( Songs::EditFields('single') ) 1459 { my $widget=Songs::EditWidget($field,'single',$ID); 1460 next unless $widget; 1461 my ($row,$col)= $widget->{noexpand} ? ($row2++,2) : ($row1++,0); 1462 if (my $w=$self->{fields}{$field}) #refresh the fields 1463 { $table->remove($w); } 1464 else #first time 1465 { my $label=Gtk2::Label->new( Songs::FieldName($field) ); 1466 $table->attach($label,$col,$col+1,$row,$row+1,'fill','shrink',2,2); 1467 } 1468 $table->attach($widget,$col+1,$col+2,$row,$row+1,['fill','expand'],'shrink',2,2); 1469 $self->{fields}{$field}=$widget; 1470 } 1471 $table->show_all; 1472} 1473 1474sub get_changes 1475{ my $self=shift; 1476 my @modif; 1477 while (my ($field,$entry)=each %{$self->{fields}}) 1478 { push @modif,$field,$entry->get_text; 1479 } 1480 return @modif; 1481} 1482 1483 1484package Edit_Embedded_Picture; 1485use base 'Gtk2::Box'; 1486 1487sub new 1488{ my ($class,$ID) = @_; 1489 my $self = bless Gtk2::VBox->new, $class; 1490 $self->{ID}=$ID; 1491 1492 $self->{store}= Gtk2::ListStore->new(qw/Glib::Uint Glib::String/); 1493 my $treeview= $self->{treeview}= Gtk2::TreeView->new($self->{store}); 1494 $treeview->insert_column_with_attributes(-1, "type",Gtk2::CellRendererText->new, text => 1); 1495 $treeview->set_headers_visible(0); 1496 $treeview->get_selection->signal_connect(changed => \&selection_changed_cb,$self); 1497 1498 my $view= $self->{view}= Layout::PictureBrowser::View->new(context_menu_sub=>\&context_menu, xalign=> .5, yalign=>.5, scroll_zoom=>1,); 1499 ::set_drag($view, dest => [::DRAG_FILE, sub 1500 { my ($view,$type,$uri,@ignored_uris)=@_; 1501 my $self= ::find_ancestor($view,__PACKAGE__); 1502 if ($uri=~s#^file://##) 1503 { my $file= ::decode_url($uri); 1504 my $data= GMB::Picture::load_data($file); 1505 $self->drop_data(\$data) if $data; 1506 } 1507 else 1508 { $self->drop_uris(uris=>[$uri]); 1509 } 1510 }], 1511 motion=> sub { my ($view,$context,$x,$y,$time)=@_; 1512 $view->{dnd_message}= _"Set picture using this file"; 1513 1; 1514 } 1515 ); 1516 $view->signal_connect(drag_leave => sub { delete $_[0]{dnd_message}; }); 1517 $self->signal_connect(destroy=> sub { my $self=shift; $self->{drop_job}->Abort if $self->{drop_job}; }); 1518 1519 my $button_del= ::NewIconButton('gtk-remove', _"Remove picture"); 1520 my $button_set= ::NewIconButton('gtk-open',_"Set picture"); 1521 my $button_new= $self->{button_new}= ::NewIconButton('gtk-add', _"Add picture"); 1522 my $combo_type= $self->{combo_type}= Gtk2::ComboBox->new_text; 1523 my $entry_desc= $self->{entry_desc}= Gtk2::Entry->new; 1524 my $info_label= $self->{info_label}= Gtk2::Label->new; 1525 $entry_desc->set_tooltip_text(_"Description"); 1526 $combo_type->set_tooltip_text(_"Picture type"); 1527 $combo_type->append_text($_) for @$EntryMulti::PICTYPE; 1528 $button_new->signal_connect(clicked=>\&new_picture_cb); 1529 $button_del->signal_connect(clicked=>\&remove_selected_cb); 1530 $button_set->signal_connect(clicked=>\&set_picture_cb); 1531 $combo_type->signal_connect(changed=>\&type_change_cb); 1532 $entry_desc->signal_connect(changed=>\&desc_changed_cb); 1533 $self->signal_connect(key_press_event=> \&key_press_cb); 1534 1535 my $hbox= ::Hpack( '_',['_',::new_scrolledwindow($treeview),$button_new], [$combo_type,$entry_desc,$button_set,$button_del] ); 1536 $self->{editbox}= $combo_type->parent; 1537 $self->pack_start($hbox, 0,0,2); 1538 $self->pack_start($view, 1,1,2); 1539 $self->pack_start($info_label, 0,0,2); 1540 $self->signal_connect(map=>sub {$_[0]->load unless $_[0]{loaded}}); 1541 return $self; 1542} 1543sub update { $_[0]->load if $_[0]{loaded}; } 1544sub load 1545{ my $self=shift; 1546 $self->{changed}=0; 1547 $self->{loaded}=1; 1548 my $ID=$self->{ID}; 1549 my $file= Songs::GetFullFilename($ID); 1550 if ($file!~m/$::EmbImage_ext_re$/) { $self->set_sensitive(0); $self->{view}->drag_dest_unset; return } 1551 my ($h)= FileTag::Read($file,0,'embedded_pictures',0); 1552 $self->{pix}= $h && $h->{embedded_pictures}; 1553 if ($file=~m/\.(?:m4a|m4b)$/i) 1554 { $self->{m4a_mode}=1; #only 1 picture, type "front cover", no description 1555 $self->{$_}->set_sensitive(0) for qw/combo_type entry_desc/; 1556 $self->{pix}= [[undef,3,'',$self->{pix}[0]]] if $self->{pix}; 1557 } 1558 $self->fill; 1559} 1560 1561sub fill 1562{ my ($self,$select)=@_; 1563 my $store= $self->{store}; 1564 $store->clear; 1565 my $pix= $self->{pix}; 1566 return unless $pix && @$pix; 1567 my $select_path; 1568 for my $nb (0..$#$pix) 1569 { next unless $pix->[$nb]; #skip deleted 1570 my $iter= $store->append; 1571 $store->set($iter, 0,$nb, 1,$self->make_row_text($nb)); 1572 $select=$nb unless defined $select; #select first by default 1573 if (defined $select && $select==$nb) { $select_path=$store->get_path($iter); } 1574 } 1575 if ($self->{m4a_mode}) 1576 { my $count= grep defined,@$pix; 1577 $self->{button_new}->set_sensitive($count==0); 1578 } 1579 if ($select_path) 1580 { $self->{treeview}->scroll_to_cell($select_path); 1581 $self->{treeview}->get_selection->select_path($select_path); 1582 } 1583} 1584sub make_row_text 1585{ my ($self,$nb)=@_; 1586 my ($mime,$typeid,$desc,$data)= @{$self->{pix}[$nb]}; 1587 my $text= $EntryMulti::PICTYPE->[$typeid] || _"Unknown"; 1588 if (defined $desc && length $desc) { $text.=": $desc" } 1589 return $text; 1590} 1591 1592sub selection_changed_cb 1593{ my ($selection,$self)=@_; 1594 my ($store,$iter) = $selection->get_selected; 1595 unless ($iter) 1596 { $self->{entry_desc}->set_text(''); 1597 $self->{combo_type}->set_active(0); 1598 } 1599 $self->{editbox}->set_sensitive(!!$iter); 1600 $self->{info_label}->set_text(""); 1601 my ($pixbuf,%info); 1602 { last unless $iter; 1603 (my $nb,$info{filename})= $store->get($iter,0,1); 1604 my $apic= $self->{pix}[$nb]; 1605 my ($mime,$typeid,$desc,$data)= @$apic; 1606 $self->{entry_desc}->set_text($desc); 1607 $self->{combo_type}->set_active($typeid); 1608 last unless $data; 1609 $info{size}=length $data; 1610 my $loader= GMB::Picture::LoadPixData($data); 1611 last unless $loader; 1612 if ($Gtk2::VERSION >= 1.092) 1613 { my $h=$loader->get_format; 1614 $self->{pix}[$nb][0]= $h->{mime_types}[0]; 1615 } 1616 $pixbuf= $loader->get_pixbuf; 1617 my $size= ::format_number($info{size}/::KB(),"%.1f").' '._"KB"; 1618 my $dim= sprintf "%d x %d",$pixbuf->get_width,$pixbuf->get_height; 1619 $self->{info_label}->set_text("($dim) $size"); 1620 } 1621 $self->{view}->reset_zoom; 1622 $self->{view}->set_pixbuf($pixbuf,%info); 1623} 1624 1625sub new_picture_cb 1626{ my $self= ::find_ancestor($_[0],__PACKAGE__); 1627 my $type=0; 1628 $type=3 unless grep $_ && $_->[1]==3, @{$self->{pix}}; # default to 3 (front cover) if no other picture of that type 1629 my $new= push @{$self->{pix}}, [undef,$type,'',undef]; 1630 $self->fill($new-1); 1631} 1632sub remove_selected_cb 1633{ my $self= ::find_ancestor($_[0],__PACKAGE__); 1634 my $nb= $self->get_selected; 1635 return unless defined $nb; 1636 $self->{changed}=1; 1637 $self->{pix}[$nb]=undef; 1638 ($nb)= grep $self->{pix}[$_],reverse 0..$nb-1; #select previous entry if any 1639 $self->fill($nb); 1640} 1641sub set_picture_cb 1642{ my $self= ::find_ancestor($_[0],__PACKAGE__); 1643 my $nb= $self->get_selected; 1644 return unless defined $nb; 1645 my $file=::ChoosePix(); 1646 return unless defined $file; 1647 $self->{changed}=1; 1648 my $data= GMB::Picture::load_data($file); 1649 $self->{pix}[$nb][3]=$data if $data; 1650 $self->fill($nb); 1651} 1652sub type_change_cb 1653{ my $combo=shift; 1654 my $self= ::find_ancestor($combo,__PACKAGE__); 1655 my $nb= $self->get_selected; 1656 return unless defined $nb; 1657 $self->{changed}=1; 1658 my $type= $combo->get_active; 1659 $self->{pix}[$nb][1]= $type; 1660 $self->refresh_selected; 1661} 1662 1663sub desc_changed_cb 1664{ my $entry=shift; 1665 my $self= ::find_ancestor($entry,__PACKAGE__); 1666 my $nb= $self->get_selected; 1667 return unless defined $nb; 1668 $self->{changed}=1; 1669 $self->{pix}[$nb][2]= $entry->get_text; 1670 $self->refresh_selected; 1671} 1672 1673sub get_selected 1674{ my $self=shift; 1675 my ($store,$iter) = $self->{treeview}->get_selection->get_selected; 1676 return unless $iter; 1677 return $store->get($iter,0); 1678} 1679sub refresh_selected 1680{ my $self=shift; 1681 my ($store,$iter) = $self->{treeview}->get_selection->get_selected; 1682 return unless $iter; 1683 my $nb=$store->get($iter,0); 1684 $store->set($iter, 1,$self->make_row_text($nb)); 1685} 1686 1687sub drop_uris 1688{ my ($self,%args)=@_; 1689 $self->{drop_job}->Abort if $self->{drop_job}; 1690 $self->{drop_job}= GMB::DropURI->new(toplevel=>$self->get_toplevel, cb=>sub{$self->drop_data($_[0]); delete $self->{drop_job}; }); 1691 my $uri= $args{uris}[0]; #only take first one 1692 my $data; 1693 $self->{drop_job}->Add_URI(uris=>[$uri], destpath=>\$data); 1694} 1695sub drop_data 1696{ my ($self,$dataref)=@_; 1697 my $nb= $self->get_selected; 1698 unless (defined $nb) 1699 { $self->new_picture_cb; 1700 $nb= $self->get_selected; 1701 return unless defined $nb; 1702 } 1703 $self->{changed}=1; 1704 $self->{pix}[$nb][3]=$$dataref if $$dataref; 1705 $self->fill($nb); 1706} 1707 1708sub context_menu_args 1709{ my $self=shift; 1710 return self=>$self, mode=>'P'; 1711} 1712sub key_press_cb 1713{ my ($self,$event)=@_; 1714 my $key=Gtk2::Gdk->keyval_name( $event->keyval ); 1715 if (::WordIn($key,'Insert KP_Insert')) { $self->new_picture_cb; } 1716 elsif (::WordIn($key,'Delete KP_Delete')) { $self->remove_selected_cb; } 1717 else {return 0} 1718 return 1; 1719} 1720 1721sub get_changes 1722{ my $self=shift; 1723 return () unless $self->{changed}; 1724 my @apics= grep $_->[3], @{$self->{pix}}; #only keep those that have a picture 1725 if ($self->{m4a_mode} && @apics) { @apics=($apics[0][3]); } 1726 return embedded_pictures=>\@apics; 1727} 1728 1729############################## Advanced tag editing ############################## 1730 1731package EditTag; 1732use base 'Gtk2::Box'; 1733 1734sub new 1735{ my ($class,$window,$ID) = @_; 1736 my $file= Songs::GetFullFilename($ID); 1737 return undef unless $file; 1738 my $self = bless Gtk2::VBox->new, $class; 1739 $self->{window}=$window; 1740 1741 my $labelfile=Gtk2::Label->new; 1742 $labelfile->set_markup( ::ReplaceFieldsAndEsc($ID,'<small>%u</small>') ); 1743 $labelfile->set_selectable(::TRUE); 1744 $labelfile->set_line_wrap(::TRUE); 1745 $self->pack_start($labelfile,::FALSE,::FALSE,1); 1746 $self->{filename}=$file; 1747 1748 my ($format)= $file=~m/\.([^.]*)$/; 1749 return undef unless $format and $format=$FileTag::FORMATS{lc$format}; 1750 $self->{filetag}=my $filetag= $format->[0]->new($file); 1751 unless ($filetag) {warn "can't read tags for $file\n";return undef;} 1752 1753 my @boxes; $self->{boxes}=\@boxes; 1754 my @tags; 1755 for my $t (split / /,$format->[2]) 1756 { if ($t eq 'vorbis' || $t eq 'ilst') {push @tags,$filetag;} 1757 elsif ($t eq 'APE') 1758 { if ($filetag->{APE}) { push @tags,$filetag->{APE}; } 1759 elsif (!@tags) { push @tags,$filetag->new_APE; } 1760 } 1761 elsif ($t eq 'ID3v2') 1762 { if ($filetag->{ID3v2}) { push @tags,$filetag->{ID3v2};push @tags, @{ $filetag->{ID3v2s} } if $filetag->{ID3v2s}; } 1763 elsif (!@tags) { push @tags,$filetag->new_ID3v2; } 1764 } 1765 } 1766 push @tags,$filetag->{lyrics3v2} if $filetag->{lyrics3v2}; 1767 1768 $self->{filetag}=$filetag; 1769 push @boxes,TagBox->new(shift @tags); 1770 push @boxes,TagBox->new($_,1) for grep defined,@tags; 1771 push @boxes,TagBox_id3v1->new($filetag,1) if $filetag->{ID3v1}; 1772 1773 my $notebook=Gtk2::Notebook->new; 1774 for my $box (grep defined, @boxes) 1775 { $notebook->append_page($box,$box->{title}); 1776 } 1777 $self->add($notebook); 1778 1779 return $self; 1780} 1781 1782sub save 1783{ my $self=shift; 1784 my $modified; 1785 for my $box (@{ $self->{boxes} }) 1786 { $modified=1 if $box->save; 1787 } 1788 $self->{filetag}{errorsub}= sub 1789 { my ($syserr,$details)= FileTag::Error_Message(@_); 1790 return ::Retry_Dialog($syserr,_"Error writing tag", details=>$details, window=>$self->{window}); 1791 }; 1792 $self->{filetag}->write_file if $modified && !$::CmdLine{ro} && !$::CmdLine{rotags}; 1793} 1794 1795package TagBox; 1796use base 'Gtk2::Box'; 1797 1798use constant 1799{ TRUE => 1, FALSE => 0, 1800 #contents of types hashes : 1801 TAGNAME => 0, TAGORDER => 1, TAGTYPE => 2, 1802}; 1803 1804my %DataType; 1805my %tagprop; 1806 1807INIT 1808{ my $id3v2_types= 1809 { #id3v2.3/4 1810 TIT2 => [_"Title",1], 1811 TIT3 => [_"Version",2], 1812 TPE1 => [_"Artist",3], 1813 TPE2 => [_"Album artist",4.5], 1814 TALB => [_"Album",4], 1815 TPOS => [_"Disc #",5], 1816 TRCK => [_"Track",6], 1817 TYER => [_"Date",7], 1818 COMM => [_"Comments",9], 1819 TCON => [_"Genre",8], 1820 TLAN => [_"Languages",20], 1821 USLT => [_"Lyrics",14], 1822 APIC => [_"Picture",15], 1823 TOPE => [_"Original Artist",40], 1824 TXXX => [_"Custom Text",50], 1825 WOAR => [_"Artist URL",50], 1826 WXXX => [_"Custom URL",50], 1827 PCNT => [_"Play counter",44], 1828 POPM => [_"Popularimeter",45], 1829 GEOB => [_"Encapsulated object",60], 1830 PRIV => [_"Private Data",98], 1831 UFID => [_"Unique file identifier",99], 1832 TCOP => [_("Copyright")." ©",80], 1833 TPRO => [_"Produced (P)",81], #FIXME find (P) symbol 1834 TCOM => [_"Composer",12], 1835 TIT1 => [_"Grouping",13], 1836 TENC => [_"Encoded by",51], 1837 TSSE => [_"Encoded with",52], 1838 TMED => [_"Media type"], 1839 TFLT => [_"File type"], 1840 TOAL => [_"Originaly from"], 1841 TOFN => [_"Original Filename"], 1842 TORY => [_"Original release year"], 1843 TPUB => [_"Label/Publisher"], 1844 TRDA => [_"Recording Dates"], 1845 TSRC => ["ISRC"], 1846 TCMP => [_"Compilation",60,'f'], 1847 }; 1848 my $vorbis_types= 1849 { title => [_"Title",1], 1850 version => [_"Version",2], 1851 artist => [_"Artist",3], 1852 album => [_"Album",4], 1853 discnumber => [_"Disc #",5], 1854 tracknumber => [_"Track",6], 1855 date => [_"Date",7], 1856 comments => [_"Comments",9,'M'], 1857 description => [_"Description",9,'M'], 1858 genre => [_"Genre",8], 1859 lyrics => [_"Lyrics",14,'L'], 1860 fmps_lyrics => [_"Lyrics",14,'L'], 1861 author => [_"Original Artist",40], 1862 metadata_block_picture=> [_"Picture",15,'tCTb'], 1863 }; 1864 my $ape_types= 1865 { title => [_"Title",1], 1866 artist => [_"Artist",3], 1867 album => [_"Album",4], 1868 subtitle => [_"Subtitle",5], 1869 publisher => [_"Publisher",14], 1870 conductor => [_"Conductor",13], 1871 track => [_"Track",6], 1872 genre => [_"Genre",8], 1873 composer => [_"Composer",12], 1874 comment => [_"Comment",9], 1875 copyright => [_"Copyright",80], 1876 publicationright=> [_"Publication right",81], 1877 year => [_"Year",7], 1878 'debut album' => [_"Debut Album",8], 1879 fmps_lyrics => [_"Lyrics",14,'L'], 1880 }; 1881 my $lyrics3v2_types= 1882 { LYR => [_"Lyrics",7,'M'], 1883 INF => [_"Info",6,'M'], 1884 AUT => [_"Author",5], 1885 EAL => [_"Album",4], 1886 EAR => [_"Artist",3], 1887 ETT => [_"Title",1], 1888 }; 1889 my $ilst_types= 1890 { "\xA9nam" => [_"Title",1], 1891 "\xA9ART" => [_"Artist",3], 1892 "\xA9alb" => [_"Album",4], 1893 "\xA9day" => [_"Year",8], 1894 "\xA9cmt" => [_"Comment",12,'M'], 1895 "\xA9gen" => [_"Genre",10], 1896 "\xA9wrt" => [_"Author",14], 1897 "\xA9lyr" => [_"Lyrics",50], 1898 "\xA9too" => [_"Encoder",51], 1899 '----' => [_"Custom",52,'ttt'], 1900 trkn => [_"Track",6], 1901 disk => [_"Disc #",7], 1902 aART => [_"Album artist",9], 1903 covr => [_"Picture",20,'p'], 1904 cpil => [_"Compilation",19,'f'], 1905 # pgap => gapless album 1906 # pcst => podcast 1907 }; 1908 1909 %tagprop= 1910 ( ID3v2 =>{ addlist => [qw/COMM TPOS TIT3 TCON TXXX TOPE WOAR WXXX USLT APIC POPM PCNT GEOB/], 1911 default => [qw/COMM TIT2 TPE1 TALB TYER TRCK TCON/], 1912 infosub => sub { Tag::ID3v2::get_fieldtypes($_[1]); }, 1913 namesub => sub { 'id3v2.'.$_[0]{version} }, 1914 types => $id3v2_types, 1915 }, 1916 OGG => { addlist => [qw/description genre discnumber author metadata_block_picture/,''], 1917 default => [qw/title artist album tracknumber date description genre/], 1918 name => 'vorbis comment', 1919 types => $vorbis_types, 1920 lckeys => 1, 1921 }, 1922 APE=> { addlist => [qw/Title Subtitle Artist Album Genre Publisher Conductor Track Composer Comment Copyright Publicationright Year/,'Debut Album'], 1923 default => [qw/Title Artist Album Track Year Genre Comment/], 1924 infosub => sub { $_[0]->is_binary($_[1],$_[2]); }, 1925 name => 'APE tag', 1926 types => $ape_types, 1927 lckeys => 1, 1928 }, 1929 Lyrics3v2=>{ addlist => [qw/EAL EAR ETT INF AUT LYR/], 1930 default => [qw/EAL EAR ETT INF/], 1931 name => 'lyrics3v2 tag', 1932 types => $lyrics3v2_types, 1933 }, 1934 M4A => { addlist => ["\xA9cmt","\xA9wrt",qw/disk aART cpil ----/], 1935 default => ["\xA9nam","\xA9ART","\xA9alb",'trkn',"\xA9day","\xA9cmt","\xA9gen"], 1936 infosub => sub {Tag::M4A::get_field_info($_[1])}, 1937 name => 'ilst', 1938 types => $ilst_types, 1939 }, 1940 ); 1941 $tagprop{Flac}=$tagprop{OGG}; 1942 1943 %DataType= 1944 ( t => ['EntrySimple'], #text 1945 T => ['EntrySimple'], #text 1946 M => ['EntryMultiLines'], #multi-line text 1947 #l => ['EntrySimple'], #3 letters language #unused, found only in multi-fields frames 1948 c => ['EntryNumber'], #counter 1949 C => ['EntryNumber',255], #1 byte integer (0-255) 1950 n => ['EntryNumber',65535], 1951 b => ['EntryBinary'], #binary 1952 u => ['EntryBinary'], #unknown -> binary 1953 f => ['EntryBoolean'], 1954 p => ['EntryCover'], 1955 L => ['EntryLyrics'], 1956 ); 1957 1958} 1959 1960sub new 1961{ my ($class,$tag,$option)=@_; 1962 my $tagtype=ref $tag; $tagtype=~s/^Tag:://i; 1963 unless ($tagprop{$tagtype}) {warn "unknown tag '$tagtype'\n"; return undef;} 1964 $tagtype=$tagprop{$tagtype}; 1965 my $self=bless Gtk2::VBox->new,$class; 1966 my $name=$tagtype->{name} || $tagtype->{namesub}($tag); 1967 $self->{title}=$name; 1968 $self->{tag}=$tag; 1969 $self->{tagtype}=$tagtype; 1970 my $sw=Gtk2::ScrolledWindow->new; 1971 #$sw->set_shadow_type('etched-in'); 1972 $sw->set_policy('automatic','automatic'); 1973 $self->{table}=my $table=Gtk2::Table->new(2,2,FALSE); 1974 $table->{row}=0; 1975 $table->{widgets}=[]; 1976 $sw->add_with_viewport($table); 1977 if ($option) 1978 { my $checkrm=Gtk2::CheckButton->new(_"Remove this tag"); 1979 $checkrm->signal_connect( toggled => sub 1980 { my $state=$_[0]->get_active; 1981 $table->{deleted}=$state; 1982 $table->set_sensitive(!$state); 1983 }); 1984 $self->pack_start($checkrm,FALSE,FALSE,2); 1985 } 1986 $self->add($sw); 1987 1988 if (my $list=$tagtype->{addlist}) 1989 { my $addbut=::NewIconButton('gtk-add',_"add"); 1990 my $addlist=Gtk2::ComboBox->new_text; 1991 my $hbox=Gtk2::HBox->new(FALSE,8); 1992 $hbox->pack_start($_,FALSE,FALSE,0) for $addlist,$addbut; 1993 $self->pack_start($hbox,FALSE,FALSE,2); 1994 for my $key (@$list) 1995 { $key=lc$key if $tagtype->{lckeys}; 1996 my $name=($key ne '')? $tagtype->{types}{$key}[TAGNAME] : _"(other)"; 1997 $addlist->append_text($name); 1998 } 1999 $addlist->set_active(0); 2000 $addbut->signal_connect( clicked => sub 2001 { my $key=$list->[ $addlist->get_active ]; 2002 $self->addrow($key); 2003 Glib::Idle->add(\&scroll_to_bottom,$self); 2004 }); 2005 } 2006 my %toadd= map { $_=>undef } $tag->get_keys; 2007 my @default= @{$tagtype->{'default'}}; 2008 my $lc= $tagtype->{lckeys}; 2009 if ($lc) { my %lc; $lc{lc()}=1 for keys %toadd; @default= grep !$lc{lc()}, @default; } 2010 $toadd{$_}=undef for @default; 2011 for my $key (sort { ($tagtype->{types}{ ($lc? lc$a : $a) }[TAGORDER]||100) 2012 <=> ($tagtype->{types}{ ($lc? lc$b : $b) }[TAGORDER]||100) } keys %toadd) 2013 { my $nb=0; 2014 $self->addrow($key,$nb++,$_) for $tag->get_values($key); 2015 $self->addrow($key) if !$nb; 2016 } 2017 2018 return $self; 2019} 2020 2021sub scroll_to_bottom 2022{ my $self=shift; 2023 my $adj= $self->{table}->parent->get_vadjustment; 2024 $adj->clamp_page($adj->upper,$adj->upper); 2025 0; #called from an idle => false to disconnect idle 2026} 2027 2028sub addrow 2029{ my ($self,$key,$nb,$value)=@_; 2030 my $table=$self->{table}; 2031 my $row=$table->{row}++; 2032 my ($widget,@Todel); 2033 my $tagtype=$self->{tagtype}; 2034 my $typesref=$tagtype->{types}{($tagtype->{lckeys}? lc$key : $key)}; 2035 2036 my ($name,$type,$realkey); 2037 if ($typesref) 2038 { $type=$typesref->[TAGTYPE]; 2039 $name=$typesref->[TAGNAME]; 2040 } 2041 if ($tagtype->{infosub}) 2042 { (my $type0,$realkey,my $fallbackname,my @extra)= $tagtype->{infosub}( $self->{tag}, $key, $nb ); 2043 $type||=$type0; 2044 $name||= $tagtype->{types}{$realkey}[TAGNAME] if $realkey; 2045 $name||= $fallbackname if $fallbackname; 2046 $value=[@extra, (ref $value ? @$value : $value)] if @extra; 2047 } 2048 $name||=$key; 2049 $type||='t'; 2050 2051 if (length($type)>1) #frame with sub-frames 2052 { $value||=[]; 2053 $widget=EntryMulti->new($value,$key,$name,$type,$realkey); 2054 $table->attach($widget,1,3,$row,$row+1,['fill','expand'],'shrink',1,1); 2055 } 2056 else #simple case : 1 label -> 1 value 2057 { $value=$value->[0] if ref $value; 2058 $value='' unless defined $value; 2059 my $label; 2060 $type=$DataType{$type}[0] || 'EntrySimple'; 2061 my $param=$DataType{$type}[1]; 2062 if ($key eq '') { ($widget,$label)=EntryDouble->new($value); } 2063 else { $widget=$type->new($value,$param); $label=Gtk2::Label->new($name); $label->set_tooltip_text($key); } 2064 $table->attach($label,1,2,$row,$row+1,'shrink','shrink',1,1); 2065 $table->attach($widget,2,3,$row,$row+1,['fill','expand'],'shrink',1,1); 2066 @Todel=($label); 2067 } 2068 push @Todel,$widget; 2069 $widget->{key}=$key; 2070 $widget->{nb}=$nb; 2071 2072 my $delbut=Gtk2::Button->new; 2073 $delbut->set_relief('none'); 2074 $delbut->add(Gtk2::Image->new_from_stock('gtk-remove','menu')); 2075 $table->attach($delbut,0,1,$row,$row+1,'shrink','shrink',1,1); 2076 $delbut->signal_connect( clicked => sub 2077 { $widget->{deleted}=1; 2078 $table->remove($_) for $_[0],@Todel; 2079 $table->{ondelete}($widget) if $table->{ondelete}; 2080 }); 2081 2082 push @{ $table->{widgets} }, $widget; 2083 $table->show_all; 2084} 2085 2086sub save 2087{ my $self=shift; 2088 my $table=$self->{table}; 2089 my $tag=$self->{tag}; 2090 if ($table->{deleted}) 2091 { $tag->removetag; 2092 warn "$tag removed\n" if $::debug; 2093 return 1; 2094 } 2095 my $modified; 2096 for my $w ( @{ $table->{widgets} } ) 2097 { if ($w->{deleted}) 2098 { next unless defined $w->{nb}; 2099 $tag->remove($w->{key},$w->{nb}); 2100 $modified=1; warn "$tag $w->{key} deleted\n" if $::debug; 2101 } 2102 else 2103 { my @v=$w->return_value; 2104 my $v= @v>1 ? \@v : $v[0]; 2105 next unless $w->{changed}; 2106 if (defined $w->{nb}) { $tag->edit($w->{key},$w->{nb},$v); } 2107 else { $tag->add( $w->{key},$v); } 2108 $modified=1; warn "$tag $w->{key} modified\n" if $::debug; 2109 } 2110 } 2111 return $modified; 2112} 2113 2114package TagBox_id3v1; 2115use base 'Gtk2::Box'; 2116 2117use constant { TRUE => 1, FALSE => 0 }; 2118 2119sub new 2120{ my ($class,$tag,$option)=@_; 2121 my $self=bless Gtk2::VBox->new, $class; 2122 $self->{title}=_"id3v1 tag"; 2123 $self->{tag}=$tag; 2124 $self->{table}=my $table=Gtk2::Table->new(2,2,FALSE); 2125 $table->{widgets}=[]; 2126 my $row=0; 2127 if ($option) 2128 { my $checkrm=Gtk2::CheckButton->new(_"Remove this tag"); 2129 $checkrm->signal_connect( toggled => sub 2130 { my $state=$_[0]->get_active; 2131 $table->{deleted}=$state; 2132 $_->set_sensitive(!$state) for grep $_ ne $_[0], $table->get_children; 2133 }); 2134 $table->attach($checkrm,0,2,$row,$row+1,'shrink','shrink',1,1); 2135 $row++; 2136 } 2137 $self->add($table); 2138 for my $aref ([_"Title",0,30],[_"Artist",1,30],[_"Album",2,30],[_"Year",3,4],[_"Comment",4,30],[_"Track",5,2]) 2139 { my $label=Gtk2::Label->new($aref->[0]); 2140 my $entry=EntrySimple->new( $tag->{ID3v1}[ $aref->[1] ], $aref->[2]); 2141 push @{ $table->{widgets} }, $entry; 2142 $table->attach($label,0,1,$row,$row+1,'shrink','shrink',1,1); 2143 $table->attach($entry,1,2,$row,$row+1,['fill','expand'],'shrink',1,1); 2144 $row++; 2145 } 2146 my $combo=EntryCombo->new($tag->{ID3v1}[6],\@Tag::MP3::Genres); 2147 push @{ $table->{widgets} }, $combo; 2148 $table->attach(Gtk2::Label->new(_"Genre"),0,1,$row,$row+1,'shrink','shrink',1,1); 2149 $table->attach($combo,1,2,$row,$row+1,['fill','expand'],'shrink',1,1); 2150 return $self; 2151} 2152 2153sub save 2154{ my $self=shift; 2155 my $table=$self->{table}; 2156 my $filetag=$self->{tag}; 2157 if ($table->{deleted}) { $filetag->{ID3v1}=undef; return 1; } 2158 my $modified; 2159 my $wgts=$table->{widgets}; 2160 my $id3v1= $filetag->{ID3v1} || $filetag->new_ID3v1; 2161 for my $i (0..5) 2162 { $id3v1->[$i]=$wgts->[$i]->return_value; 2163 $modified=1 if $wgts->[$i]{changed}; 2164 } 2165 $id3v1->[6]= $wgts->[6]->return_value; 2166 $modified=1 if $wgts->[6]{changed}; 2167 return $modified; 2168} 2169 2170package EntrySimple; 2171use base 'Gtk2::Entry'; 2172 2173sub new 2174{ my ($class,$init,$len) = @_; 2175 my $self = bless Gtk2::Entry->new, $class; 2176 $self->set_text($init); 2177 $self->set_width_chars($len) if $len; 2178 $self->set_max_length($len) if $len; 2179 $self->{init}=$init; 2180 return $self; 2181} 2182sub return_value 2183{ my $self=shift; 2184 my $value=$self->get_text; 2185 #warn "$self '$value' '$self->{init}'" if $value ne $self->{init}; 2186 $self->{changed}=1 if $value ne $self->{init}; 2187 return $value; 2188} 2189 2190package EntryMultiLines; 2191use base 'Gtk2::ScrolledWindow'; 2192 2193sub new 2194{ my ($class,$init) = @_; 2195 my $self = bless Gtk2::ScrolledWindow->new, $class; 2196 $self->set_shadow_type('etched-in'); 2197 $self->set_policy('automatic','automatic'); 2198 my $textview= $self->{textview}= Gtk2::TextView->new; 2199 $textview->set_size_request(100,($textview->create_pango_layout("X")->get_pixel_size)[1]*4); #request 4 lines of height 2200 $self->add($textview); 2201 $self->set_text($init); 2202 $self->{init}=$self->get_text; 2203 return $self; 2204} 2205sub set_text 2206{ my $self=shift; 2207 $self->{textview}->get_buffer->set_text(shift); 2208} 2209sub get_text 2210{ my $self=shift; 2211 my $buffer=$self->{textview}->get_buffer; 2212 return $buffer->get_text( $buffer->get_bounds, 1); 2213} 2214sub return_value 2215{ my $self=shift; 2216 my $value=$self->get_text; 2217 $self->{changed}=1 if $value ne $self->{init}; 2218 return $value; 2219} 2220 2221package EntryDouble; 2222use base 'Gtk2::Entry'; 2223 2224sub new 2225{ my ($class,$init) = @_; 2226 my $self = bless Gtk2::Entry->new, $class; 2227 #$self->set_text($init); 2228 #$self->{init}=$init; 2229 $self->{keyEntry}=Gtk2::Entry->new; 2230 return $self,$self->{keyEntry}; 2231} 2232sub return_value 2233{ my $self=shift; 2234 my $value=$self->get_text; 2235 $self->{key}=$self->{keyEntry}->get_text; 2236 $self->{changed}=1 if ($self->{key} ne '' && $value ne ''); 2237 return $value; 2238} 2239 2240package EntryNumber; 2241use base 'Gtk2::SpinButton'; 2242 2243sub new 2244{ my ($class,$init,$max) = @_; 2245 my $self = bless Gtk2::SpinButton->new( 2246 Gtk2::Adjustment->new ($init||0, 0, $max||10000000, 1, 10, 0) ,10,0 ) 2247 , $class; 2248 $self->{init}=$self->get_value; 2249 return $self; 2250} 2251sub return_value 2252{ my $self=shift; 2253 my $value=$self->get_value; 2254 $self->{changed}=1 if $value ne $self->{init}; 2255 return $value; 2256} 2257 2258package EntryBoolean; 2259use base 'Gtk2::CheckButton'; 2260 2261sub new 2262{ my ($class,$init) = @_; 2263 my $self = bless Gtk2::CheckButton->new, $class; 2264 $self->set_active(1) if $init; 2265 $self->{init}=$init; 2266 return $self; 2267} 2268sub return_value 2269{ my $self=shift; 2270 my $value=$self->get_active; 2271 $self->{changed}=1 if ($value xor $self->{init}); 2272 return $value; 2273} 2274package EntryCombo; 2275use base 'Gtk2::ComboBox'; 2276 2277sub new 2278{ my ($class,$init,$listref) = @_; 2279 my $self = bless Gtk2::ComboBox->new_text, $class; 2280 if ($init && $init=~m/\D/) 2281 { my $text=$init; 2282 $init=''; 2283 for my $i (0..$#$listref) 2284 { if ($listref->[$i] eq $text) {$init=$i;last} 2285 } 2286 } 2287 for my $text (@$listref) 2288 { $self->append_text($text); 2289 } 2290 $self->set_active($init) unless $init eq ''; 2291 $self->{init}=$init; 2292 return $self; 2293} 2294sub return_value 2295{ my $self=shift; 2296 my $value=$self->get_active; 2297 $value='' if $value==-1; 2298 $self->{changed}=1 if $value ne $self->{init}; 2299 return $value; 2300} 2301 2302package EntryMulti; #for id3v2 frames containing multiple fields 2303use base 'Gtk2::Frame'; 2304 2305my %SUBTAGPROP; our $PICTYPE; 2306INIT 2307{ $PICTYPE=[_"other",_"32x32 PNG file icon",_"other file icon",_"front cover",_"back cover",_"leaflet page",_"media",_"lead artist",_"artist",_"conductor",_"band",_"composer",_"lyricist",_"recording location",_"during recording",_"during performance",_"movie/video screen capture",_"a bright coloured fish",_"illustration",_"band/artist logotype",_"Publisher/Studio logotype"]; 2308 %SUBTAGPROP= # [label,row,col_start,col_end,widget,extra_parameter] 2309 ( USLT => [ [_"Lang.",0,1,2,'EntrySimple',3], 2310 [_"Descr.",0,3,5], 2311 ['',1,0,5,'EntryLyrics'] 2312 ], 2313 COMM => [ [_"Lang",0,1,2,'EntrySimple',3], 2314 [_"Descr.",0,3,5], 2315 ['',1,0,5] 2316 ], 2317 APIC => [ [_"MIME type",0,1,5], 2318 [_"Picture Type",1,1,5,'EntryCombo',$PICTYPE], 2319 [_"Description",2,1,5], 2320 ['',3,0,5,'EntryCover'] 2321 ], 2322 GEOB => [ [_"MIME type",0,1,5], 2323 [_"Filename",1,1,5], 2324 [_"Description",2,1,5], 2325 ['',3,0,5,'EntryBinary'] #FIXME load & save & launch? 2326 ], 2327 TXXX => [ [_"Descr.",0,1,2], 2328 [_"Text",1,1,2] 2329 ], 2330 WXXX => [ [_"Descr.",0,1,2], 2331 [_"URL",1,1,2] #FIXME URL click 2332 ], 2333 POPM => [ [_"email",0,1,4], 2334 [_"Rating",1,1,2], 2335 [_"counter",1,3,4] 2336 ], 2337 USER => [ [_"Lang",0,1,2,'EntrySimple',3], 2338 [_"Terms of use",1,1,4] 2339 ], 2340 OWNE => [ [_"Price paid",0,1,2], 2341 [_"Date of purchase",1,1,2], 2342 [_"Seller",2,1,2], 2343 ], 2344 UFID => [ [_"Owner identifier",0,1,2], 2345 ['',1,0,2,'EntryBinary'] 2346 ], 2347 PRIV => [ [_"Owner identifier",0,1,2], 2348 ['',1,0,2,'EntryBinary'] 2349 ], 2350 '----' => 2351 [ [_"Application",0,1,2], 2352 [_"Name",1,1,2], 2353 ['',2,0,2], 2354 ], 2355 'com.apple.iTunes----FMPS_Lyrics'=> 2356 [ [_"Application",0,1,2], 2357 [_"Name",1,1,2], 2358 ['',2,0,2,'EntryLyrics'], 2359 ], 2360 ); 2361 $SUBTAGPROP{metadata_block_picture}=$SUBTAGPROP{APIC}; #for vorbis pictures 2362} 2363 2364sub new 2365{ my ($class,$values,$key,$name,$type,$realkey) = @_; 2366 my $self = bless Gtk2::Frame->new($name), $class; 2367 my $table=Gtk2::Table->new(1, 4, 0); 2368 $self->add($table); 2369 my $prop= $SUBTAGPROP{$key}; 2370 $prop||= $SUBTAGPROP{$realkey} if $realkey; 2371 my $row=0; 2372 my $subtag=0; 2373 for my $t (split //,$type) 2374 { my $val=$$values[$subtag]; $val='' unless defined $val; 2375 my ($name,$frow,$cols,$cole,$widget,$param)= 2376 ($prop) ? @{ $prop->[$subtag] } 2377 : (_"unknown",$row++,1,5,undef,undef); 2378 unless ($widget) 2379 { ($widget,$param)=@{ $DataType{$t} }; 2380 } 2381 $subtag++; 2382 if ($name ne '') 2383 { my $label=Gtk2::Label->new($name); 2384 $table->attach($label,$cols-1,$cols,$frow,$frow+1,'shrink','shrink',1,1); 2385 } 2386 $widget=$widget->new( $val,$param ); 2387 push @{ $self->{widgets} },$widget; 2388 $table->attach($widget,$cols,$cole,$frow,$frow+1,['fill','expand'],'shrink',1,1); 2389 } 2390 if ($key eq 'APIC') { $self->{widgets}[3]->set_mime_entry($self->{widgets}[0]); } 2391 2392 return $self; 2393} 2394 2395sub return_value 2396{ my $self=shift; 2397 my @values; 2398 for my $w ( @{ $self->{widgets} } ) 2399 { my @v=$w->return_value; 2400 $self->{changed}=1 if $w->{changed}; 2401 push @values,@v; 2402 } 2403 return @values; 2404} 2405 2406package EntryBinary; 2407use base 'Gtk2::Button'; 2408 2409sub new 2410{ my $class = shift; 2411 my $self = bless Gtk2::Button->new(_"View binary data ..."), $class; 2412 $self->{init}=$self->{value}=shift; 2413 $self->signal_connect(clicked => \&view); 2414 return $self; 2415} 2416sub return_value 2417{ my $self=shift; 2418 #$self->{changed}=1 if $self->{value} ne $self->{init}; 2419 return $self->{value}; 2420} 2421sub view 2422{ my $self=$_[0]; 2423 my $dialog = Gtk2::Dialog->new (_"View Binary", $self->get_toplevel, 2424 'destroy-with-parent', 2425 'gtk-close' => 'close'); 2426 $dialog->set_default_response ('close'); 2427 my $text; 2428 my $offset=0; 2429 while (my $b=substr $self->{value},$offset,16) 2430 { $text.=sprintf "%08x %-48s", $offset, join ' ',unpack '(H2)*',$b; 2431 $offset+=length $b; 2432 $b=~s/[^[:print:]]/./g; #replace non-printable with '.' 2433 $text.=" $b\n"; 2434 } 2435 my $textview=Gtk2::TextView->new; 2436 my $buffer=$textview->get_buffer; 2437 $buffer->set_text($text); 2438 $textview->modify_font(Gtk2::Pango::FontDescription->from_string('Monospace')); 2439 $textview->set_editable(0); 2440 2441 my $sw=Gtk2::ScrolledWindow->new; 2442 $sw->set_shadow_type('etched-in'); 2443 $sw->set_policy('never', 'automatic'); 2444 $sw->add($textview); 2445 $dialog->vbox->add($sw); 2446 $dialog->set_default_size(100,100); 2447 $dialog->show_all; 2448 $dialog->signal_connect( response => sub { $_[0]->destroy; }); 2449} 2450 2451package EntryCover; 2452use base 'Gtk2::Box'; 2453 2454sub new 2455{ my $class = shift; 2456 my $self = bless Gtk2::HBox->new, $class; 2457 $self->{init}=$self->{value}=shift; 2458 my $img=$self->{img}=Gtk2::Image->new; 2459 my $vbox=Gtk2::VBox->new; 2460 my $eventbox=Gtk2::EventBox->new; 2461 $eventbox->add($img); 2462 $self->add($_) for $eventbox,$vbox; 2463 my $label=$self->{label}=Gtk2::Label->new; 2464 my $Bload=::NewIconButton('gtk-open',_"Replace..."); 2465 my $Bsave=::NewIconButton('gtk-save-as',_"Save as..."); 2466 $vbox->pack_start($_,0,0,2) for $label,$Bload,$Bsave; 2467 $Bload->signal_connect(clicked => \&load_cb); 2468 $Bsave->signal_connect(clicked => \&save_cb); 2469 $eventbox->signal_connect(button_press_event => \&GMB::Picture::pixbox_button_press_cb); 2470 $self->{Bsave}=$Bsave; 2471 ::set_drag($self, dest => [::DRAG_FILE,\&uri_dropped]); 2472 2473 $self->set; 2474 2475 return $self; 2476} 2477sub set_mime_entry 2478{ my $self=shift; 2479 $self->{mime_entry}=shift; 2480 $self->update_mime; 2481} 2482sub return_value 2483{ my $self=shift; 2484 $self->{changed}=1 if $self->{value} ne $self->{init} && length $self->{value}; 2485 return $self->{value}; 2486} 2487sub set 2488{ my $self=shift; 2489 my $label=$self->{label}; 2490 my $Bsave=$self->{Bsave}; 2491 my $length=length $self->{value}; 2492 unless ($length) { $label->set_text(_"empty"); $Bsave->set_sensitive(0); return; } 2493 my $loader= GMB::Picture::LoadPixData( $self->{value} ,'-150'); 2494 my $pixbuf; 2495 if (!$loader) 2496 { $label->set_text(_"error"); 2497 $Bsave->set_sensitive(0); 2498 ($self->{ext},$self->{mime})=('',''); 2499 } 2500 else 2501 { $pixbuf=$loader->get_pixbuf; 2502 $Bsave->set_sensitive(1); 2503 if ($Gtk2::VERSION >= 1.092) 2504 { my $h=$loader->get_format; 2505 $self->{ext} =$h->{extensions}[0]; 2506 $self->{mime}=$h->{mime_types}[0]; 2507 } 2508 else 2509 { ($self->{ext},$self->{mime})=_identify_pictype($self->{value}); 2510 } 2511 $label->set_text("$loader->{w} x $loader->{h} ($self->{ext} $length bytes)"); 2512 } 2513 my $img=$self->{img}; 2514 $img->set_from_pixbuf($pixbuf); 2515 $self->update_mime if $self->{mime_entry}; 2516 $img->parent->{pixdata}=$self->{value}; #for zoom on click 2517} 2518sub uri_dropped 2519{ my ($self,$type,$uri)=@_; 2520 if ($uri=~s#^file://##) 2521 { my $file=::decode_url($uri); 2522 $self->load_file($file); 2523 } 2524 #else #FIXME download http link 2525} 2526sub load_file 2527{ my ($self,$file)=@_; 2528 my $data= GMB::Picture::load_data($file); 2529 return unless $data; 2530 $self->{value}=$data; 2531 $self->set; 2532} 2533sub load_cb 2534{ my $self=::find_ancestor($_[0],__PACKAGE__); 2535 my $file=::ChoosePix(); 2536 $self->load_file($file) if defined $file; 2537} 2538sub save_cb 2539{ my $self=::find_ancestor($_[0],__PACKAGE__); 2540 return unless length $self->{value}; 2541 my $file=::ChooseSaveFile($self->{window},_"Save picture as",undef,'picture.'.$self->{ext}); 2542 return unless defined $file; 2543 open my$fh,'>',$file or return; 2544 print $fh $self->{value}; 2545 close $fh; 2546} 2547 2548sub update_mime 2549{ my $self=shift; 2550 return unless $self->{mime}; 2551 $self->{mime_entry}->set_text($self->{mime}); 2552} 2553 2554sub _identify_pictype #used only if $Gtk2::VERSION < 1.092 2555{ $_[0]=~m/^\xff\xd8\xff\xe0..JFIF\x00/s && return ('jpg','image/jpeg'); 2556 $_[0]=~m/^\x89PNG\x0D\x0A\x1A\x0A/ && return ('png','image/png'); 2557 $_[0]=~m/^GIF8[79]a/ && return ('gif','image/gif'); 2558 $_[0]=~m/^BM/ && return ('bmp','image/bmp'); 2559 return ('',''); 2560} 2561 2562package EntryLyrics; 2563use base 'Gtk2::Button'; 2564 2565sub new 2566{ my $class = shift; 2567 my $self = bless Gtk2::Button->new(_"Edit Lyrics ..."), $class; 2568 $self->{init}=$self->{value}=shift; 2569 $self->signal_connect(clicked => \&edit); 2570 return $self; 2571} 2572sub return_value 2573{ my $self=shift; 2574 $self->{changed}=1 if $self->{value} ne $self->{init}; 2575 return $self->{value}; 2576} 2577sub edit 2578{ my $self=$_[0]; 2579 if ($self->{dialog}) { $self->{dialog}->force_present; return } 2580 $self->{dialog}= 2581 ::EditLyricsDialog( $self->get_toplevel, $self->{value},undef, sub 2582 { my $lyrics=shift; 2583 $self->{value}=$lyrics if defined $lyrics; 2584 $self->{dialog}=undef; 2585 }); 2586} 25871; 2588