Initial commit
[fillmore] / src / marina / project.vala
1 /* Copyright 2009-2010 Yorba Foundation
2  *
3  * This software is licensed under the GNU Lesser General Public License
4  * (version 2.1 or later).  See the COPYING file in this distribution. 
5  */
6
7 using Logging;
8
9 extern const string _VERSION;
10
11 namespace Model {
12 public class MediaLoaderHandler : LoaderHandler {
13     protected weak Project the_project;
14     protected Track current_track;
15
16     Gee.ArrayList<ClipFetcher> clipfetchers = new Gee.ArrayList<ClipFetcher>();
17     int num_clipfiles_complete;
18
19     public MediaLoaderHandler(Project the_project) {
20         this.the_project = the_project;
21         current_track = null;
22     }
23
24     public override bool commit_marina(string[] attr_names, string[] attr_values) {
25         int number_of_attributes = attr_names.length;
26         if (number_of_attributes != 1 ||
27             attr_names[0] != "version") {
28             load_error("Missing version information");
29             return false;
30         }
31
32         if (the_project.get_file_version() < attr_values[0].to_int()) {
33             load_error("Version mismatch! (File Version: %d, App Version: %d)".printf(
34                 the_project.get_file_version(), attr_values[0].to_int()));
35             return false;
36         }
37
38         num_clipfiles_complete = 0;
39         return true;
40     }
41
42     public override bool commit_library(string[] attr_names, string[] attr_values) {
43         // We return true since framerate is an optional parameter
44         if (attr_names.length != 1)
45             return true;
46
47         if (attr_names[0] != "framerate") {
48             load_error("Missing framerate tag");
49             return false;
50         }
51
52         string[] arr = attr_values[0].split("/");
53         if (arr.length != 2) {
54             load_error("Invalid framerate attribute");
55             return false;
56         }
57
58         the_project.set_default_framerate(Fraction(arr[0].to_int(), arr[1].to_int()));
59         return true;
60     }
61
62     public override bool commit_track(string[] attr_names, string[] attr_values) {
63         assert(current_track == null);
64
65         int number_of_attributes = attr_names.length;
66         string? name = null;
67         string? type = null;
68         for (int i = 0; i < number_of_attributes; ++i) {
69             switch(attr_names[i]) {
70                 case "type":
71                     type = attr_values[i];
72                     break;
73                 case "name":
74                     name = attr_values[i];
75                     break;
76                 default:
77                     break;
78             }
79         }
80
81         if (name == null) {
82             load_error("Missing track name");
83             return false;
84         }
85
86         if (type == null) {
87             load_error("Missing track type");
88             return false;
89         }
90
91         if (type == "audio") {
92             AudioTrack audio_track = new AudioTrack(the_project, name);
93             current_track = audio_track;
94             the_project.add_track(current_track);
95
96             for (int i = 0; i < number_of_attributes; ++i) {
97                 switch(attr_names[i]) {
98                     case "panorama":
99                         audio_track._set_pan(attr_values[i].to_double());
100                         break;
101                     case "volume":
102                         audio_track._set_volume(attr_values[i].to_double());
103                         break;
104                     case "channels":
105                         audio_track.set_default_num_channels(attr_values[i].to_int());
106                         break;
107                     default:
108                         break;
109                 }
110             }
111             return true;
112         } else if (type == "video") {
113             current_track = new VideoTrack(the_project);
114             the_project.add_track(current_track);
115         }
116
117         return base.commit_track(attr_names, attr_values);
118     }
119
120     public override void leave_track() {
121         assert(current_track != null);
122         current_track = null;
123     }
124
125     public override bool commit_clip(string[] attr_names, string[] attr_values) {
126         assert(current_track != null);
127
128         int number_of_attributes = attr_names.length;
129         int id = -1;
130         string? clip_name = null;
131         int64 start = -1;
132         int64 media_start = -1;
133         int64 duration = -1;
134         for (int i = 0; i < number_of_attributes; i++) {
135         switch (attr_names[i]) {
136             case "id":
137                 id = attr_values[i].to_int();
138                 break;
139             case "name":
140                 clip_name = attr_values[i];
141                 break;
142             case "start":
143                 start = attr_values[i].to_int64();
144                 break;
145             case "media-start":
146                 media_start = attr_values[i].to_int64();
147                 break;
148             case "duration":
149                 duration = attr_values[i].to_int64();
150                 break;
151             default:
152                 // TODO: we need a way to deal with orphaned attributes, for now, reject the file
153                 load_error("Unknown attribute %s".printf(attr_names[i]));
154                 return false;
155             }
156         }
157
158         if (id == -1) {
159             load_error("missing clip id");
160             return false;
161         }
162
163         if (clip_name == null) {
164             load_error("missing clip_name");
165             return false;
166         }
167
168         if (start == -1) {
169             load_error("missing start time");
170             return false;
171         }
172
173         if (media_start == -1) {
174             load_error("missing media_start");
175             return false;
176         }
177
178         if (duration == -1) {
179             load_error("missing duration");
180             return false;
181         }
182
183         if (id >= clipfetchers.size) {
184             load_error("clip file id %s was not loaded".printf(clip_name));
185             return false;
186         }
187
188         Clip clip = new Clip(clipfetchers[id].clipfile, current_track.media_type(), clip_name, 
189             start, media_start, duration, false);
190         current_track.add(clip, start, false);
191         return true;
192     }
193
194     void fetcher_ready(Fetcher f) {
195         emit(this, Facility.SIGNAL_HANDLERS, Level.INFO, "fetcher_ready");
196         if (f.error_string != null) {
197             load_error("Could not load %s.".printf(f.clipfile.filename));
198             warning("Could not load %s: %s", f.clipfile.filename, f.error_string);
199         }
200         the_project.add_clipfile(f.clipfile);
201         num_clipfiles_complete++;
202         if (num_clipfiles_complete == clipfetchers.size) {
203             complete();
204         }
205     }
206
207     public override bool commit_clipfile(string[] attr_names, string[] attr_values) {
208         string filename = null;
209         int id = -1;
210
211         for (int i = 0; i < attr_names.length; i++) {
212             if (attr_names[i] == "filename") {
213                 filename = attr_values[i];
214             } else if (attr_names[i] == "id") {
215                 id = attr_values[i].to_int();
216             }
217         }
218
219         if (filename == null) {
220             load_error("Invalid clipfile filename");
221             return false;
222         }
223
224         if (id < 0) {
225             load_error("Invalid clipfile id");
226             return false;
227         }
228
229         try {
230             ClipFetcher fetcher = new ClipFetcher(filename);
231             fetcher.ready.connect(fetcher_ready);
232             clipfetchers.insert(id, fetcher);
233         } catch (Error e) {
234             load_error(e.message);
235             return false;
236         }
237         return true;
238     }
239
240     public override bool commit_tempo_entry(string[] attr_names, string[] attr_values) {
241         if (attr_names[0] != "tempo") {
242             load_error("Invalid attribute on tempo entry");
243             return false;
244         }
245
246         the_project._set_bpm(attr_values[0].to_int());
247         return true;
248     }
249
250     public override bool commit_time_signature_entry(string[] attr_names, string[] attr_values) {
251         if (attr_names[0] != "signature") {
252             load_error("Invalid attribute on time signature");
253             return false;
254         }
255
256         the_project._set_time_signature(Fraction.from_string(attr_values[0]));
257         return true;
258     }
259
260     public override bool commit_click(string[] attr_names, string[] attr_values) {
261         for (int i = 0; i < attr_names.length; ++i) {
262             switch (attr_names[i]) {
263                 case "on_play":
264                     the_project.click_during_play = attr_values[i] == "true";
265                 break;
266                 case "on_record":
267                     the_project.click_during_record = attr_values[i] == "true";
268                 break;
269                 case "volume":
270                     the_project.click_volume = attr_values[i].to_double();
271                 break;
272                 default:
273                     load_error("unknown attribute for click '%s'".printf(attr_names[i]));
274                     return false;
275             }
276         }
277         return true;
278     }
279
280     public override bool commit_library_preference(string[] attr_names, string[] attr_values) {
281         for (int i = 0; i < attr_names.length; ++i) {
282             switch (attr_names[i]) {
283                 case "width":
284                     the_project.library_width = attr_values[i].to_int();
285                 break;
286                 case "visible":
287                     the_project.library_visible = attr_values[i] == "true";
288                 break;
289                 default:
290                     load_error("unknown attribute for library '%s'".printf(attr_names[i]));
291                     return false;
292             }
293         }
294         return true;
295     }
296
297     public override void leave_library() {
298         if (clipfetchers.size == 0)
299             complete();
300     }
301 }
302
303 public abstract class Project : TempoInformation, Object {
304     public const string FILLMORE_FILE_EXTENSION = "fill";
305     public const string FILLMORE_FILE_FILTER = "*." + FILLMORE_FILE_EXTENSION;   
306     public const string LOMBARD_FILE_EXTENSION = "lom";
307     public const string LOMBARD_FILE_FILTER = "*." + LOMBARD_FILE_EXTENSION;
308
309     const string license = """
310 %s is free software; you can redistribute it and/or modify it under the 
311 terms of the GNU Lesser General Public License as published by the Free 
312 Software Foundation; either version 2.1 of the License, or (at your option) 
313 any later version.
314
315 %s is distributed in the hope that it will be useful, but WITHOUT 
316 ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
317 FOR A PARTICULAR PURPOSE.  See the GNU Lesser General Public License for 
318 more details.
319
320 You should have received a copy of the GNU Lesser General Public License 
321 along with %s; if not, write to the Free Software Foundation, Inc., 
322 51 Franklin St, Fifth Floor, Boston, MA  02110-1301 USA
323 """;
324
325     public const string[] authors = { 
326         "Robert Powell <rob@yorba.org>",
327         "Adam Dingle <adam@yorba.org>",
328         "Andrew O'Mahony <andrew.omahony@att.net>",
329         "Dru Moore <usr@dru-id.co.uk>",
330         null
331     };
332
333     public Gee.ArrayList<Track> tracks = new Gee.ArrayList<Track>();
334     public Gee.ArrayList<Track> inactive_tracks = new Gee.ArrayList<Track>();
335     Gee.HashSet<ClipFetcher> pending = new Gee.HashSet<ClipFetcher>();
336     Gee.ArrayList<ThumbnailFetcher> pending_thumbs = new Gee.ArrayList<ThumbnailFetcher>();
337     protected Gee.ArrayList<ClipFile> clipfiles = new Gee.ArrayList<ClipFile>();
338     // TODO: media_engine is a member of project only temporarily.  It will be
339     // less work to move it to fillmore/lombard once we have a transport class.
340     public View.MediaEngine media_engine;
341
342     protected string project_file;  // may be null if project has not yet been saved
343     public ProjectLoader loader;
344
345     FetcherCompletion fetcher_completion;
346     public UndoManager undo_manager;
347     public LibraryImporter importer;
348
349     public Fraction default_framerate;
350     int tempo = 120;
351     Fraction time_signature = Fraction(4, 4);
352     public bool click_during_play = false;
353     public bool click_during_record = true;
354     public double click_volume = 0.8;
355     public bool library_visible = true;
356     public int library_width = 600;
357     public bool snap_to_clip;
358
359     /* TODO:
360      * This can't be const since the Vala compiler (0.7.7) crashes if we try to make it a const.
361      * I've filed a bug with the Vala bugzilla for this:
362      * https://bugzilla.gnome.org/show_bug.cgi?id=598204
363      */    
364     public static Fraction INVALID_FRAME_RATE = Fraction(-1, 1);
365
366     public signal void playstate_changed(PlayState playstate);
367
368     public signal void name_changed(string? project_file);
369     public signal void load_error(string error);
370     public virtual signal void load_complete() {
371     }
372
373     public signal void closed();
374
375     public signal void track_added(Track track);
376     public signal void track_removed(Track track);
377     public signal void error_occurred(string major_message, string? minor_message);
378
379     public signal void clipfile_added(ClipFile c);
380     public signal void clipfile_removed(ClipFile clip_file);
381     public signal void cleared();
382
383     public abstract TimeCode get_clip_time(ClipFile f);
384
385     public Project(string? filename, bool include_video) throws Error {
386         undo_manager = new UndoManager();
387         project_file = filename;
388
389         media_engine = new View.MediaEngine(this, include_video);
390         track_added.connect(media_engine.on_track_added);
391         media_engine.playstate_changed.connect(on_playstate_changed);
392         media_engine.error_occurred.connect(on_error_occurred);
393
394         set_default_framerate(INVALID_FRAME_RATE);
395     }
396
397     public void on_playstate_changed() {
398         emit(this, Facility.SIGNAL_HANDLERS, Level.INFO, "on_playstate_changed");
399         switch (media_engine.get_play_state()) {
400             case PlayState.STOPPED:
401                 ClearTrackMeters();
402                 break;
403             case PlayState.CLOSED:
404                 closed();
405                 break;
406         }
407         playstate_changed(media_engine.get_play_state());
408     }
409
410     public virtual string? get_project_file() {
411         return project_file;
412     }
413
414     public ClipFile? get_clipfile(int index) {
415         if (index < 0 ||
416             index >= clipfiles.size)
417             return null;
418         return clipfiles[index];
419     }
420
421     public int get_clipfile_index(ClipFile find) {
422         int i = 0;
423         foreach (ClipFile f in clipfiles) {
424             if (f == find)
425                 return i;
426             i++;
427         }
428         return -1;
429     }
430
431     public Track? track_from_clip(Clip clip) {
432         foreach (Track track in tracks) {
433             foreach (Clip match in track.clips) {
434                 if (match == clip) {
435                     return track;
436                 }
437             }
438         }
439         return null;
440     }
441
442     public void print_graph(Gst.Bin bin, string file_name) {
443         Gst.debug_bin_to_dot_file_with_ts(bin, Gst.DebugGraphDetails.ALL, file_name);
444     }
445
446     public int64 get_length() {
447         int64 max = 0;
448         foreach (Track track in tracks) {
449             max = int64.max(max, track.get_length());
450         }
451         return max;
452     }
453
454     public int64 snap_clip(Clip c, int64 span) {
455         foreach (Track track in tracks) {
456             int64 new_start = track.snap_clip(c, span);
457             if (new_start != c.start) {
458                 return new_start;
459             }
460         }
461         return c.start;
462     }
463
464     public void snap_coord(out int64 coord, int64 span) {
465         foreach (Track track in tracks) {
466             if (track.snap_coord(out coord, span)) {
467                 break;
468             }
469         }
470     }
471
472     Gap get_gap_intersection(Gap gap) {
473         Gap intersection = gap;
474
475         foreach (Track track in tracks) {
476             intersection = intersection.intersect(track.find_first_gap(intersection.start));
477         }
478
479         return intersection;
480     }
481
482     public bool can_delete_gap(Gap gap) {
483         Gap intersection = get_gap_intersection(gap);
484         return !intersection.is_empty();
485     }
486
487     public void delete_gap(Gap gap) {
488         Gap intersection = get_gap_intersection(gap);
489         assert(!intersection.is_empty());
490
491         foreach (Track track in tracks) {
492             track.delete_gap(intersection);
493         }
494     }
495
496     protected virtual void do_append(Track track, ClipFile clipfile, string name, 
497         int64 insert_time) {
498         switch(track.media_type()) {
499             case MediaType.AUDIO:
500                 if (clipfile.audio_caps == null) {
501                     return;
502                 }
503                 break;
504             case MediaType.VIDEO:
505                 if (clipfile.video_caps == null) {
506                     return;
507                 }
508             break;
509         }
510
511         Clip clip = new Clip(clipfile, track.media_type(), name, 0, 0, clipfile.length, false);
512         track.append_at_time(clip, insert_time, true);
513     }
514
515     public void append(Track track, ClipFile clipfile) {
516         string name = isolate_filename(clipfile.filename);
517         int64 insert_time = 0;
518
519         foreach (Track temp_track in tracks) {
520             insert_time = int64.max(insert_time, temp_track.get_length());
521         }
522         do_append(track, clipfile, name, insert_time);
523     }
524
525     public void add(Track track, ClipFile clipfile, int64 time) {
526         string name = isolate_filename(clipfile.filename);
527         do_append(track, clipfile, name, time);
528     }
529
530     public void on_clip_removed(Track t, Clip clip) {
531         emit(this, Facility.SIGNAL_HANDLERS, Level.INFO, "on_clip_removed");
532         reseek();
533     }
534
535     public void split_at_playhead() {
536         string description = "Split At Playhead";
537         undo_manager.start_transaction(description);
538         foreach (Track track in tracks) {
539             if (track.get_clip_by_position(transport_get_position()) != null) {
540                 track.split_at(transport_get_position());
541             }
542         }
543         undo_manager.end_transaction(description);
544     }
545
546     public bool can_trim(out bool left) {
547         Clip first_clip = null;
548
549         // When trimming multiple clips, we allow trimming left only if both clips already start
550         // at the same position, and trimming right only if both clips already end at the same
551         // position.
552
553         int64 start = 0;
554         int64 end = 0;
555         bool start_same = true;
556         bool end_same = true;
557         foreach (Track track in tracks) {
558             Clip clip = track.get_clip_by_position(transport_get_position());
559             if (first_clip != null && clip != null) {
560                 start_same = start_same && start == clip.start;
561                 end_same = end_same && end == clip.end;
562             } else if (clip != null) {
563                 first_clip = clip;
564                 start = first_clip.start;
565                 end = first_clip.end;
566             }
567         }
568
569         if (first_clip == null) {
570             return false;
571         }
572
573         if (start_same && !end_same) {
574             left = true;
575             return true;
576         }
577
578         if (!start_same && end_same) {
579             left = false;
580             return true;
581         }
582
583         // which half of the clip are we closer to?
584         left = (transport_get_position() - first_clip.start < first_clip.duration / 2);
585         return true;
586     }
587
588     public void trim_to_playhead() {
589         bool left;
590         if (!can_trim(out left)) {
591             return;
592         }
593         string description = "Trim To Playhead";
594         Clip first_clip = null;
595         undo_manager.start_transaction(description);
596         foreach (Track track in tracks) {
597             Clip clip = track.get_clip_by_position(transport_get_position());
598             if (clip != null) {
599                 int64 delta;
600                 if (left) {
601                     delta = transport_get_position() - clip.start;
602                 } else {
603                     delta = transport_get_position() - clip.end;
604                 }
605                 track.trim(clip, delta, left ? Gdk.WindowEdge.WEST : Gdk.WindowEdge.EAST);
606             }
607         }
608         undo_manager.end_transaction(description);
609
610         if (left && first_clip != null) {
611             transport_go(first_clip.start);
612         }
613     }
614
615     public void transport_go(int64 position) {
616         media_engine.go(position);
617     }
618
619     public bool transport_is_playing() {
620         return media_engine.playing;
621     }
622
623     public bool transport_is_recording() {
624         return media_engine.get_play_state() == PlayState.PRE_RECORD ||
625                media_engine.get_play_state() == PlayState.RECORDING;
626     }
627
628     public bool playhead_on_clip() {
629         foreach (Track track in tracks) {
630             if (track.get_clip_by_position(transport_get_position()) != null) {
631                 return true;
632             }
633         }
634         return false;
635     }
636
637     public bool playhead_on_contiguous_clip() {
638         foreach (Track track in tracks) {
639             if (track.are_contiguous_clips(transport_get_position())) {
640                 return true;
641             }
642         }
643         return false;
644     }
645
646     public bool is_duplicate_track_name(Track? track, string new_name) {
647         assert(new_name != "");
648         foreach (Track this_track in tracks) {
649             if (track != this_track) {
650                 if (this_track.get_display_name() == new_name) {
651                     return true;
652                 }
653             }
654         }
655         
656         foreach (Track this_track in inactive_tracks) {
657             if (track != this_track) {
658                 if (this_track.get_display_name() == new_name) {
659                     return true;
660                 }
661             }
662         }
663         return false;
664     }
665
666     public virtual void add_track(Track track) {
667         track.clip_removed.connect(on_clip_removed);
668         track.error_occurred.connect(on_error_occurred);
669         tracks.add(track);
670         track_added(track);
671     }
672     public virtual void insert_track(int index, Track track) {
673         if (0 > index) index = 0;
674         if (tracks.size <= index) {
675           add_track(track);
676         }
677         else {
678           track.clip_removed.connect(on_clip_removed);
679           track.error_occurred.connect(on_error_occurred);
680           tracks.insert(index, track);
681           track_added(track);
682         }
683     }
684
685     public void add_inactive_track(Track track) {
686         track.hide();
687         inactive_tracks.add(track);
688     }
689
690     public void remove_track(Track track) {
691         media_engine.pipeline.set_state(Gst.State.NULL);
692         track.track_removed(track);
693         tracks.remove(track);
694         track_removed(track);
695     }
696     
697     public void remove_track_at(int index) {
698         remove_track(tracks.get(index));
699     }
700
701     public void add_clipfile(ClipFile clipfile) {
702         Model.Command command = new Model.AddClipCommand(this, clipfile);
703         do_command(command);
704     }
705
706     public void _add_clipfile(ClipFile clipfile) throws Error {
707         clipfiles.add(clipfile);
708         if (clipfile.is_online() && clipfile.is_of_type(MediaType.VIDEO)) {
709             ThumbnailFetcher fetcher = new ThumbnailFetcher(clipfile, 0);
710             fetcher.ready.connect(on_thumbnail_ready);
711             pending_thumbs.add(fetcher);
712         } else {
713             clipfile_added(clipfile);
714         }
715     }
716
717     void on_thumbnail_ready(Fetcher f) {
718         emit(this, Facility.SIGNAL_HANDLERS, Level.INFO, "on_thumbnail_ready");
719         clipfile_added(f.clipfile);
720         pending_thumbs.remove(f as ThumbnailFetcher);
721     }
722
723     public bool clipfile_on_track(string filename) {
724         ClipFile cf = find_clipfile(filename);
725
726         foreach (Track t in tracks) {
727             foreach (Clip c in t.clips) {
728                 if (c.clipfile == cf)
729                     return true;
730             }
731         }
732
733         foreach (Track t in inactive_tracks) {
734             foreach (Clip c in t.clips) {
735                 if (c.clipfile == cf)
736                     return true;
737             }
738         }
739
740         return false;
741     }
742
743     void delete_clipfile_from_tracks(ClipFile cf) {
744         foreach (Track t in tracks) {
745             for (int i = 0; i < t.clips.size; i++) {
746                 if (t.clips[i].clipfile == cf) {
747                     t.delete_clip(t.clips[i]);
748                     i --;
749                 }
750             }
751         }
752
753         foreach (Track t in inactive_tracks) {
754             for (int i = 0; i < t.clips.size; i++) {
755                 if (t.clips[i].clipfile == cf) {
756                     t.delete_clip(t.clips[i]);
757                     i --;
758                 }
759             }
760         }
761     }
762
763     public void _remove_clipfile(ClipFile cf) {
764         clipfiles.remove(cf);
765         clipfile_removed(cf);
766     }
767
768     public void remove_clipfile(string filename) {
769         ClipFile cf = find_clipfile(filename);
770         if (cf != null) {
771             string description = "Delete From Library";
772             undo_manager.start_transaction(description);
773
774             delete_clipfile_from_tracks(cf);
775
776             Command clipfile_delete = new ClipFileDeleteCommand(this, cf);
777             do_command(clipfile_delete);
778
779             undo_manager.end_transaction(description);
780         }
781     }
782     
783     public ClipFile? find_clipfile(string filename) {
784         foreach (ClipFile cf in clipfiles)
785             if (cf.filename == filename)
786                 return cf;
787         return null;
788     }
789
790     public void reseek() { transport_go(transport_get_position()); }
791
792     public void go_start() { transport_go(0); }
793
794     public void go_end() { transport_go(get_length()); }
795
796     public void go_previous() {
797         int64 start_pos = transport_get_position();
798
799         // If we're currently playing, we jump to the previous clip if we're within the first
800         // second of the current clip.
801         if (transport_is_playing())
802             start_pos -= 1 * Gst.SECOND;
803
804         int64 new_position = 0;
805         foreach (Track track in tracks) {
806             new_position = int64.max(new_position, track.previous_edit(start_pos));
807         }
808         transport_go(new_position);
809     }
810
811     // Move to the next clip boundary after the current transport position.
812     public void go_next() {
813         int64 start_pos = transport_get_position();
814         int64 new_position = get_length();
815         foreach (Track track in tracks) {
816             if (track.get_length() > start_pos) {
817                 new_position = int64.min(new_position, track.next_edit(start_pos));
818             }
819         }
820         transport_go(new_position);
821     }
822
823     public int64 transport_get_position() {
824         return media_engine.position;
825     }
826
827     public void set_name(string? filename) {
828         if (filename != null) {
829             this.project_file = filename;
830         }
831         name_changed(filename);
832     }
833
834     public void set_default_framerate(Fraction rate) {
835         default_framerate = rate;
836     }
837
838     public string get_file_display_name() {
839         string filename = get_project_file();
840         if (filename == null) {
841             return "Unsaved Project - %s".printf(get_app_name());
842         }
843         else {
844             string dir = Path.get_dirname(filename);
845             string name = Path.get_basename(filename);
846             string home_path = GLib.Environment.get_home_dir();
847
848             if (dir == ".")
849                 dir = GLib.Environment.get_current_dir();
850
851             if (dir.has_prefix(home_path))
852                 dir = "~" + dir.substring(home_path.length);
853             return "%s (%s) - %s".printf(name, dir, get_app_name());
854         }
855     }
856
857     public void clear() {
858         media_engine.set_gst_state(Gst.State.NULL);
859
860         foreach (Track track in tracks) {
861             track.delete_all_clips();
862             track.track_removed(track);
863             track_removed(track);
864         }
865
866         tracks.clear();
867         
868         clipfiles.clear();
869         set_name(null);
870         cleared();
871     }
872
873     public bool can_export() {
874         if (media_engine.get_play_state() != PlayState.STOPPED) {
875             return false;
876         }
877         foreach (Track track in tracks) {
878             if (track.get_length() > 0) {
879                 return true;
880             }
881         }
882         return false;
883     }
884
885     public void on_load_started(string filename) {
886         emit(this, Facility.SIGNAL_HANDLERS, Level.INFO, "on_load_started");
887         project_file = filename;
888     }
889
890     void on_load_error(string error) {
891         emit(this, Facility.SIGNAL_HANDLERS, Level.INFO, "on_load_error");
892         load_error(error);
893     }
894
895     void on_load_complete() {
896         emit(this, Facility.SIGNAL_HANDLERS, Level.INFO, "on_load_complete");
897         undo_manager.reset();
898         set_name(project_file);
899         load_complete(); 
900     }
901
902     // Load a project file.  The load is asynchronous: it may continue after this method returns.
903     // Any load error will be reported via the load_error signal, which may run either while this
904     // method executes or afterward.
905     public virtual void load(string? fname) {
906         emit(this, Facility.LOADING, Level.INFO, "loading project");
907         clear();
908         set_name(null);
909         if (fname == null) {
910             return;
911         }
912
913         loader = new ProjectLoader(new MediaLoaderHandler(this), fname);
914
915         loader.load_started.connect(on_load_started);
916         loader.load_error.connect(on_load_error);
917         loader.load_complete.connect(on_load_complete);
918         loader.load_complete.connect(media_engine.on_load_complete);
919         media_engine.set_play_state(PlayState.LOADING);
920         media_engine.pipeline.set_state(Gst.State.NULL);
921         loader.load();
922     }
923
924     public void on_error_occurred(string major_error, string? minor_error) {
925         emit(this, Facility.SIGNAL_HANDLERS, Level.INFO, "on_error_occurred");
926         error_occurred(major_error, minor_error);
927     }
928
929     public int get_file_version() {
930         return 4;
931     }
932
933     public void save_library(FileStream f) {
934         f.printf("  <library");
935
936         Fraction r = default_framerate;
937
938         foreach (Track t in tracks) {
939             if (t.media_type () == MediaType.VIDEO) {
940                 VideoTrack video_track = t as VideoTrack;
941                 if (video_track.get_framerate(out r))
942                     break;
943             }
944         }
945         if (!r.equal(INVALID_FRAME_RATE))
946             f.printf(" framerate=\"%d/%d\"", r.numerator, 
947                                              r.denominator);
948         f.printf(">\n");
949
950         for (int i = 0; i < clipfiles.size; i++) {
951             f.printf("    <clipfile filename=\"%s\" id=\"%d\"/>\n", clipfiles[i].filename, i);
952         }
953
954         f.printf("  </library>\n");
955     }
956
957     public virtual void save(string? filename) {
958         if (filename != null) {
959             set_name(filename);
960         }
961
962         FileStream f = FileStream.open(project_file, "w");
963         if (f == null) {
964             error_occurred("Could not save project",
965                 "%s: %s".printf(project_file, GLib.strerror(GLib.errno)));
966             return;
967         }
968         f.printf("<marina version=\"%d\">\n", get_file_version());
969
970         save_library(f);
971
972         f.printf("  <tracks>\n");
973         foreach (Track track in tracks) {
974             track.save(f);
975         }
976         
977         foreach (Track track in inactive_tracks) {
978             track.save(f);
979         }
980         f.printf("  </tracks>\n");
981         f.printf("  <preferences>\n");
982         f.printf("    <click on_play=\"%s\" on_record=\"%s\" volume=\"%lf\"/>\n", 
983             click_during_play ? "true" : "false",
984             click_during_record ? "true" : "false",
985             click_volume);
986         f.printf("    <library width=\"%d\" visible=\"%s\" />\n",
987             library_width, library_visible ? "true" : "false");
988         f.printf("  </preferences>\n");
989         f.printf("  <maps>\n");
990         f.printf("    <tempo>\n");
991         f.printf("      <entry tempo=\"%d\" />\n", tempo);
992         f.printf("    </tempo>\n");
993         f.printf("    <time_signature>\n");
994         f.printf("      <entry signature=\"%s\" />\n", time_signature.to_string());
995         f.printf("    </time_signature>\n");
996         f.printf("  </maps>\n");
997
998         f.printf("</marina>\n");
999         f.flush();
1000
1001         // TODO: clean up responsibility between dirty and undo
1002         undo_manager.mark_clean();
1003     }
1004
1005     public void close() {
1006         media_engine.close();
1007     }
1008
1009     public void on_importer_clip_complete(ClipFetcher fetcher) {
1010         if (fetcher.error_string != null) {
1011             error_occurred("Error importing clip", fetcher.error_string);
1012         } else {
1013             fetcher_completion.complete(fetcher);
1014         }
1015     }
1016
1017     public void create_clip_fetcher(FetcherCompletion fetcher_completion, string filename) 
1018             throws Error {
1019         ClipFetcher fetcher = new ClipFetcher(filename);
1020         this.fetcher_completion = fetcher_completion;
1021         fetcher.ready.connect(on_fetcher_ready);
1022         pending.add(fetcher);
1023     }
1024
1025     // TODO: We should be using Library importer rather than this mechanism for fillmore
1026     void on_fetcher_ready(Fetcher fetcher) {
1027         emit(this, Facility.SIGNAL_HANDLERS, Level.INFO, "on_fetcher_ready");
1028         pending.remove(fetcher as ClipFetcher);
1029         if (fetcher.error_string != null) {
1030             emit(this, Facility.DEVELOPER_WARNINGS, Level.INFO, fetcher.error_string);
1031             error_occurred("Error retrieving clip", fetcher.error_string);
1032         } else {
1033             if (get_clipfile_index(fetcher.clipfile) == -1) {
1034                 add_clipfile(fetcher.clipfile);
1035             }
1036             fetcher_completion.complete(fetcher);
1037         }
1038     }
1039
1040     public bool is_project_extension(string filename) {
1041         string extension = get_file_extension(filename);
1042         return extension == LOMBARD_FILE_EXTENSION || extension == FILLMORE_FILE_EXTENSION;
1043     }
1044
1045     public void do_command(Command the_command) {
1046         undo_manager.do_command(the_command);
1047     }
1048
1049     public void undo() {
1050         undo_manager.undo();
1051     }
1052
1053     void ClearTrackMeters() {
1054         foreach (Track track in tracks) {
1055             AudioTrack audio_track = track as AudioTrack;
1056             if (audio_track != null) {
1057                 audio_track.level_changed(-100, -100);
1058             }
1059         }
1060     }
1061
1062     public void create_clip_importer(Model.Track? track, bool timeline_add, 
1063             int64 time_to_add, bool both_tracks, Gtk.Window? progress_window_parent, int number) {
1064         if (timeline_add) {
1065             assert(track != null);
1066             importer = new Model.TimelineImporter(track, this, time_to_add, both_tracks);
1067         } else {
1068             importer = new Model.LibraryImporter(this);
1069         }
1070         if (progress_window_parent != null) {
1071             new MultiFileProgress(progress_window_parent, number, "Import", 
1072                 importer.importer);
1073         }
1074
1075     }
1076
1077     public string get_version() {
1078         return _VERSION;
1079     }
1080
1081     public abstract string get_app_name();
1082
1083     public string get_license() {
1084         return license.printf(get_app_name(), get_app_name(), get_app_name());
1085     }
1086
1087     public void set_time_signature(Fraction time_signature) {
1088         TimeSignatureCommand command = new TimeSignatureCommand(this, time_signature);
1089         undo_manager.do_command(command);
1090     }
1091
1092     public void _set_time_signature(Fraction time_signature) {
1093         this.time_signature = time_signature;
1094         time_signature_changed(time_signature);
1095     }
1096
1097     public Fraction get_time_signature() {
1098         return time_signature;
1099     }
1100
1101     public void set_bpm(int bpm) {
1102         BpmCommand command = new BpmCommand(this, bpm);
1103         undo_manager.do_command(command);
1104     }
1105
1106     public void _set_bpm(int bpm) {
1107         this.tempo = bpm;
1108         bpm_changed(bpm);
1109     }
1110
1111     public int get_bpm() {
1112         return tempo;
1113     }
1114
1115     public string get_audio_path() {
1116         string path = get_path();
1117         return path == null ? null : Path.build_filename(path, "audio files");
1118     }
1119
1120     string get_path() {
1121         return project_file == null ? null : Path.get_dirname(project_file);
1122     }
1123
1124     public VideoTrack? find_video_track() {
1125         foreach (Track track in tracks) {
1126             if (track is VideoTrack) {
1127                 return track as VideoTrack;
1128             }
1129         }
1130         return null;
1131     }
1132
1133     public AudioTrack? find_audio_track() {
1134         foreach (Track track in tracks) {
1135             if (track is AudioTrack) {
1136                 return track as AudioTrack;
1137             }
1138         }
1139         return null;
1140     }
1141 }
1142 }
1143