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