diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index e9943a9..557a2e5 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,13 +1,24 @@ +default_stages: [ pre-push ] +fail_fast: false + repos: - repo: https://github.com/pre-commit/pre-commit-hooks rev: v5.0.0 hooks: - id: trailing-whitespace + args: [ --markdown-linebreak-ext=md ] - id: end-of-file-fixer - - id: check-yaml + - id: mixed-line-ending - id: check-merge-conflict + - id: detect-private-key + - id: check-yaml + - id: check-toml + - id: check-json + - id: check-docstring-first - id: check-added-large-files - args: [ '--maxkb=600' ] + args: [ --maxkb=500 ] + - id: pretty-format-json + args: [ --autofix, --no-sort-keys ] - repo: local hooks: @@ -16,18 +27,17 @@ repos: entry: make format language: system pass_filenames: false - types: [ java ] + stages: [ pre-commit ] - id: lint - name: Check code style + name: Run the linter checks entry: make lint language: system pass_filenames: false - types: [ java ] + stages: [ pre-commit ] - id: test - name: Run tests + name: Run the tests entry: make test language: system pass_filenames: false - stages: [ pre-commit ] diff --git a/Makefile b/Makefile index 777b247..fdf14c5 100644 --- a/Makefile +++ b/Makefile @@ -52,14 +52,16 @@ clean: ## Remove all build artifacts @echo "Cleaning project..." @$(MVN) -B clean -setup-hooks: ## Set up pre-commit hooks - @echo "Setting up pre-commit hooks..." +setup-hooks: ## Install Git hooks (pre-commit and pre-push) + @echo "Setting up Git hooks..." @if ! command -v pre-commit &> /dev/null; then \ echo "pre-commit not found. Please install it using 'pip install pre-commit'"; \ exit 1; \ fi - @pre-commit install --install-hooks + @pre-commit install --hook-type pre-commit + @pre-commit install --hook-type pre-push + @pre-commit install-hooks -test-hooks: ## Test pre-commit hooks on all files - @echo "Testing pre-commit hooks..." - @./scripts/test-pre-commit.sh +test-hooks: ## Test Git hooks on all files + @echo "Testing Git hooks..." + @pre-commit run --all-files --show-diff-on-failure diff --git a/README.md b/README.md index 355ed99..7447bf9 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,6 @@ [![Code Quality](https://img.shields.io/codefactor/grade/github/pixel-clover/uview?label=quality&style=flat&labelColor=282c34&logo=codefactor)](https://www.codefactor.io/repository/github/pixel-clover/uview) [![License](https://img.shields.io/badge/license-Apache--2.0-blue?style=flat&labelColor=282c34&logo=open-source-initiative)](LICENSE) [![Release](https://img.shields.io/github/release/pixel-clover/uview.svg?label=release&style=flat&labelColor=282c34&logo=github)](https://github.com/pixel-clover/uview/releases/latest) -[![Downloads](https://img.shields.io/github/downloads/pixel-clover/uview/total?label=downloads&style=flat&labelColor=282c34&logo=github)](https://github.com/pixel-clover/uview/releases) A cross-platform tool for viewing and modifying Unity package files @@ -93,4 +92,4 @@ Box logo is from [SVG Repo](https://www.svgrepo.com/svg/366323/package-inspect). ### License -UView is available under the Apache License, Version 2.0 ([LICENSE](LICENSE)). +UView is available under the Apache License, Version 2.0 (see [LICENSE](LICENSE)). diff --git a/pom.xml b/pom.xml index f55412d..3ee873f 100644 --- a/pom.xml +++ b/pom.xml @@ -6,7 +6,7 @@ io.github.pixelclover.uview uview - 0.1.0-b.2 + 0.1.0 jar UView @@ -18,6 +18,7 @@ 21 ${java.version} + 21.0.2 3.4.1 1.26.2 5.10.2 @@ -101,6 +102,16 @@ imageio-tiff 3.10.1 + + com.twelvemonkeys.imageio + imageio-jpeg + 3.10.1 + + + com.twelvemonkeys.imageio + imageio-tga + 3.10.1 + org.apache.pdfbox pdfbox @@ -116,6 +127,21 @@ vorbisspi 1.0.3.3 + + org.openjfx + javafx-controls + ${javafx.version} + + + org.openjfx + javafx-media + ${javafx.version} + + + org.openjfx + javafx-swing + ${javafx.version} + @@ -183,6 +209,7 @@ io.github.pixelclover.uview.App + diff --git a/scripts/test-pre-commit.sh b/scripts/test-pre-commit.sh deleted file mode 100755 index 22994f1..0000000 --- a/scripts/test-pre-commit.sh +++ /dev/null @@ -1,24 +0,0 @@ -#!/bin/bash -# Script to test pre-commit hooks without committing - -set -e - -echo "Testing pre-commit hooks..." - -# Check if pre-commit is installed -if ! command -v pre-commit &> /dev/null; then - echo "Error: pre-commit is not installed. Please install it with 'pip install pre-commit'" - exit 1 -fi - -# Check if pre-commit hooks are installed -if [ ! -f .git/hooks/pre-commit ]; then - echo "Pre-commit hooks are not installed. Installing now..." - pre-commit install --install-hooks -fi - -# Run pre-commit on all files -echo "Running pre-commit on all files..." -pre-commit run --all-files - -echo "All pre-commit hooks passed!" diff --git a/src/main/java/io/github/pixelclover/uview/App.java b/src/main/java/io/github/pixelclover/uview/App.java index f736c6b..1bd772f 100644 --- a/src/main/java/io/github/pixelclover/uview/App.java +++ b/src/main/java/io/github/pixelclover/uview/App.java @@ -5,7 +5,7 @@ import com.formdev.flatlaf.extras.FlatUIDefaultsInspector; import io.github.pixelclover.uview.gui.FontManager; import io.github.pixelclover.uview.gui.MainWindow; -import javax.swing.*; +import javax.swing.SwingUtilities; /** The main entry point for the UView application. */ public final class App { diff --git a/src/main/java/io/github/pixelclover/uview/core/PackageManager.java b/src/main/java/io/github/pixelclover/uview/core/PackageManager.java index 3ec02c2..0d0baf6 100644 --- a/src/main/java/io/github/pixelclover/uview/core/PackageManager.java +++ b/src/main/java/io/github/pixelclover/uview/core/PackageManager.java @@ -20,18 +20,30 @@ */ public class PackageManager { private static final Logger LOGGER = LogManager.getLogger(PackageManager.class); - private static final long MAX_ASSET_SIZE_BYTES = 512 * 1024 * 1024; // 512 MB limit - private final PackageIO packageIO; + private static final long DEFAULT_MAX_ASSET_SIZE_BYTES = 512 * 1024 * 1024; // 512 MB limit + private final PackageIO packageIo; + private final long maxAssetSizeBytes; private UnityPackage activePackage = new UnityPackage(); private boolean isModified = false; /** - * Constructs a PackageManager with a given PackageIO handler. + * Constructs a PackageManager with a given PackageIO handler and default max asset size. * - * @param packageIO The I/O handler for reading and writing package files. + * @param packageIo The I/O handler for reading and writing package files. */ - public PackageManager(PackageIO packageIO) { - this.packageIO = packageIO; + public PackageManager(PackageIO packageIo) { + this(packageIo, DEFAULT_MAX_ASSET_SIZE_BYTES); + } + + /** + * Constructs a PackageManager with a given PackageIO handler and a custom max asset size. + * + * @param packageIo The I/O handler for reading and writing package files. + * @param maxAssetSizeBytes The maximum allowed size for an asset in bytes. + */ + public PackageManager(PackageIO packageIo, long maxAssetSizeBytes) { + this.packageIo = packageIo; + this.maxAssetSizeBytes = maxAssetSizeBytes; } /** Creates a new, empty package, discarding any existing active package data. */ @@ -47,7 +59,7 @@ public void createNew() { * @throws IOException If an error occurs during file loading. */ public void loadPackage(File packageFile) throws IOException { - activePackage = packageIO.load(packageFile); + activePackage = packageIo.load(packageFile); isModified = false; LOGGER.info("Loaded package: {}", packageFile.getAbsolutePath()); } @@ -59,7 +71,7 @@ public void loadPackage(File packageFile) throws IOException { * @throws IOException If an error occurs during file saving. */ public void savePackage(File packageFile) throws IOException { - packageIO.save(activePackage, packageFile); + packageIo.save(activePackage, packageFile); isModified = false; LOGGER.info("Saved package: {}", packageFile.getAbsolutePath()); } @@ -110,11 +122,11 @@ public Collection getAssetsUnderPath(String pathPrefix) { * @throws IOException If the file is too large or cannot be read. */ public void addAsset(Path sourceFile, String assetPath) throws IOException { - if (Files.size(sourceFile) > MAX_ASSET_SIZE_BYTES) { + if (Files.size(sourceFile) > maxAssetSizeBytes) { throw new IOException( String.format( "File is too large (%.1f MB). Maximum allowed size is %d MB.", - Files.size(sourceFile) / (1024.0 * 1024.0), MAX_ASSET_SIZE_BYTES / (1024 * 1024))); + Files.size(sourceFile) / (1024.0 * 1024.0), maxAssetSizeBytes / (1024 * 1024))); } byte[] content = Files.readAllBytes(sourceFile); String metaGuid = java.util.UUID.randomUUID().toString().replace("-", ""); diff --git a/src/main/java/io/github/pixelclover/uview/core/SettingsManager.java b/src/main/java/io/github/pixelclover/uview/core/SettingsManager.java index 107eaa7..0869e8a 100644 --- a/src/main/java/io/github/pixelclover/uview/core/SettingsManager.java +++ b/src/main/java/io/github/pixelclover/uview/core/SettingsManager.java @@ -1,7 +1,7 @@ package io.github.pixelclover.uview.core; import io.github.pixelclover.uview.App; -import java.awt.*; +import java.awt.Font; import java.io.File; import java.util.ArrayList; import java.util.List; @@ -18,7 +18,11 @@ public class SettingsManager { private static final String LAST_DIRECTORY_KEY = "last_directory"; private static final String RECENT_FILE_PREFIX = "recent_file_"; - private static final String THEME_IS_DARK_KEY = "theme_is_dark"; + private static final String THEME_KEY = "theme"; + public static final String THEME_LIGHT = "Light"; + public static final String THEME_DARK = "Dark"; + public static final String THEME_SYSTEM = "System"; + private static final String FONT_SIZE_KEY = "font_size"; private static final String FONT_FAMILY_KEY = "font_family"; private static final int DEFAULT_FONT_SIZE = 12; @@ -41,21 +45,21 @@ public SettingsManager(Preferences prefs) { } /** - * Checks if the dark theme is currently enabled. + * Gets the current theme. * - * @return true if dark theme is set, false otherwise. + * @return The current theme, e.g., "Light", "Dark", or "System". */ - public boolean isDarkTheme() { - return prefs.getBoolean(THEME_IS_DARK_KEY, false); + public String getTheme() { + return prefs.get(THEME_KEY, THEME_SYSTEM); } /** * Sets the theme preference. * - * @param isDark true to enable dark theme, false for light theme. + * @param theme The theme to set, e.g., "Light", "Dark", or "System". */ - public void setDarkTheme(boolean isDark) { - prefs.putBoolean(THEME_IS_DARK_KEY, isDark); + public void setTheme(String theme) { + prefs.put(THEME_KEY, theme); } /** @@ -96,7 +100,9 @@ public void setFontFamily(String fontFamily) { /** Resets all UI-related settings (theme and font) to their default states. */ public void resetUiSettings() { - prefs.remove(THEME_IS_DARK_KEY); + prefs.remove(THEME_KEY); + // For backward compatibility, remove the old key + prefs.remove("theme_is_dark"); prefs.remove(FONT_SIZE_KEY); prefs.remove(FONT_FAMILY_KEY); } diff --git a/src/main/java/io/github/pixelclover/uview/gui/AboutDialog.java b/src/main/java/io/github/pixelclover/uview/gui/AboutDialog.java new file mode 100644 index 0000000..bd28125 --- /dev/null +++ b/src/main/java/io/github/pixelclover/uview/gui/AboutDialog.java @@ -0,0 +1,116 @@ +package io.github.pixelclover.uview.gui; + +import com.formdev.flatlaf.extras.FlatSVGIcon; +import io.github.pixelclover.uview.App; +import java.awt.BorderLayout; +import java.awt.Desktop; +import java.awt.Dimension; +import java.awt.FlowLayout; +import java.awt.Font; +import java.awt.event.MouseAdapter; +import java.awt.event.MouseEvent; +import java.io.IOException; +import java.io.InputStream; +import java.net.URI; +import java.util.Properties; +import javax.swing.BorderFactory; +import javax.swing.Box; +import javax.swing.BoxLayout; +import javax.swing.JButton; +import javax.swing.JDialog; +import javax.swing.JFrame; +import javax.swing.JLabel; +import javax.swing.JPanel; +import javax.swing.JTextArea; + +public class AboutDialog extends JDialog { + + public AboutDialog(JFrame owner) { + super(owner, "About UView", true); + setLayout(new BorderLayout()); + + JPanel contentPanel = new JPanel(); + contentPanel.setLayout(new BoxLayout(contentPanel, BoxLayout.Y_AXIS)); + contentPanel.setBorder(BorderFactory.createEmptyBorder(20, 20, 20, 20)); + + // Logo and Title + FlatSVGIcon logo = new FlatSVGIcon("logo.svg", 64, 64); + JLabel logoLabel = new JLabel(logo); + logoLabel.setAlignmentX(CENTER_ALIGNMENT); + contentPanel.add(logoLabel); + + contentPanel.add(Box.createRigidArea(new Dimension(0, 10))); + + JLabel titleLabel = new JLabel("UView"); + titleLabel.setFont(new Font(titleLabel.getFont().getName(), Font.BOLD, 20)); + titleLabel.setAlignmentX(CENTER_ALIGNMENT); + contentPanel.add(titleLabel); + + JLabel versionLabel = new JLabel("Version: " + getAppVersion()); + versionLabel.setAlignmentX(CENTER_ALIGNMENT); + contentPanel.add(versionLabel); + + contentPanel.add(Box.createRigidArea(new Dimension(0, 20))); + + // Description + JTextArea description = + new JTextArea("A desktop tool for viewing and modifying Unity packages."); + description.setEditable(false); + description.setLineWrap(true); + description.setWrapStyleWord(true); + description.setBackground(getBackground()); + description.setAlignmentX(CENTER_ALIGNMENT); + description.setMaximumSize(new Dimension(300, Integer.MAX_VALUE)); + contentPanel.add(description); + + contentPanel.add(Box.createRigidArea(new Dimension(0, 10))); + + // GitHub Link + JLabel githubLabel = + new JLabel("https://github.com/pixel-clover/uview"); + githubLabel.setCursor(new java.awt.Cursor(java.awt.Cursor.HAND_CURSOR)); + githubLabel.setAlignmentX(CENTER_ALIGNMENT); + githubLabel.addMouseListener( + new MouseAdapter() { + @Override + public void mouseClicked(MouseEvent e) { + try { + Desktop.getDesktop().browse(new URI("https://github.com/pixel-clover/uview")); + } catch (Exception ex) { + // Ignore + } + } + }); + contentPanel.add(githubLabel); + + contentPanel.add(Box.createRigidArea(new Dimension(0, 20))); + + // Close Button + JPanel buttonPanel = new JPanel(new FlowLayout(FlowLayout.RIGHT)); + JButton closeButton = new JButton("Close"); + closeButton.addActionListener(e -> dispose()); + buttonPanel.add(closeButton); + + add(contentPanel, BorderLayout.CENTER); + add(buttonPanel, BorderLayout.SOUTH); + + pack(); + setResizable(false); + setLocationRelativeTo(owner); + } + + private String getAppVersion() { + try (InputStream is = + App.class.getResourceAsStream( + "/META-INF/maven/io.github.pixelclover/uview/pom.properties")) { + if (is == null) { + return "N/A"; + } + Properties props = new Properties(); + props.load(is); + return props.getProperty("version", "N/A"); + } catch (IOException e) { + return "N/A"; + } + } +} diff --git a/src/main/java/io/github/pixelclover/uview/gui/AssetViewerFrame.java b/src/main/java/io/github/pixelclover/uview/gui/AssetViewerFrame.java index 98dc3d2..56f71c3 100644 --- a/src/main/java/io/github/pixelclover/uview/gui/AssetViewerFrame.java +++ b/src/main/java/io/github/pixelclover/uview/gui/AssetViewerFrame.java @@ -2,7 +2,9 @@ import io.github.pixelclover.uview.core.PackageManager; import io.github.pixelclover.uview.model.UnityAsset; -import java.awt.*; +import java.awt.BorderLayout; +import java.awt.Dimension; +import java.awt.FlowLayout; import java.awt.event.WindowAdapter; import java.awt.event.WindowEvent; import java.io.File; @@ -11,7 +13,15 @@ import java.nio.file.Files; import java.text.DecimalFormat; import java.util.Set; -import javax.swing.*; +import javax.swing.BorderFactory; +import javax.swing.Box; +import javax.swing.BoxLayout; +import javax.swing.ImageIcon; +import javax.swing.JButton; +import javax.swing.JFrame; +import javax.swing.JLabel; +import javax.swing.JPanel; +import javax.swing.JScrollPane; /** * A frame for viewing and potentially editing a single {@link UnityAsset}. It determines the @@ -55,6 +65,7 @@ public class AssetViewerFrame extends JFrame { private final Runnable onSaveCallback; private PdfViewerPanel pdfPanel; private AudioPlayerPanel audioPanel; + private VideoPlayerPanel videoPanel; /** * Constructs a new AssetViewerFrame. @@ -99,6 +110,9 @@ public void windowClosed(WindowEvent e) { if (audioPanel != null) { audioPanel.close(); } + if (videoPanel != null) { + videoPanel.stop(); + } } }); } @@ -167,8 +181,20 @@ private JPanel createContentPanel(UnityAsset asset) { contentWrapperPanel.add(errorLabel, BorderLayout.CENTER); } } else if (VIDEO_EXTENSIONS.contains(extension)) { - handleMediaAsset(asset); - return null; + try { + File tempFile = + Files.createTempFile( + "uview-preview-", asset.assetPath().replaceAll("[^a-zA-Z0-9.-]", "_")) + .toFile(); + tempFile.deleteOnExit(); + Files.write(tempFile.toPath(), asset.content()); + this.videoPanel = new VideoPlayerPanel(asset, tempFile.toPath()); + contentWrapperPanel.add(this.videoPanel, BorderLayout.CENTER); + } catch (IOException e) { + JLabel errorLabel = new JLabel("Failed to load video: " + e.getMessage()); + errorLabel.setHorizontalAlignment(JLabel.CENTER); + contentWrapperPanel.add(errorLabel, BorderLayout.CENTER); + } } else { contentWrapperPanel.add( new JLabel("Binary content cannot be previewed."), BorderLayout.CENTER); @@ -177,8 +203,6 @@ private JPanel createContentPanel(UnityAsset asset) { } private JPanel createTextEditorPanel(UnityAsset asset) { - JPanel editorPanel = new JPanel(new BorderLayout(0, 5)); - JButton saveButton = new JButton("Save"); saveButton.setEnabled(false); JButton revertButton = new JButton("Revert"); @@ -206,41 +230,13 @@ private JPanel createTextEditorPanel(UnityAsset asset) { buttonPanel.add(revertButton); buttonPanel.add(saveButton); + JPanel editorPanel = new JPanel(new BorderLayout(0, 5)); editorPanel.add(syntaxTextPanel, BorderLayout.CENTER); editorPanel.add(buttonPanel, BorderLayout.SOUTH); return editorPanel; } - private void handleMediaAsset(UnityAsset asset) { - try { - File tempFile = - Files.createTempFile( - "uview-preview-", asset.assetPath().replaceAll("[^a-zA-Z0-9.-]", "_")) - .toFile(); - tempFile.deleteOnExit(); - assert asset.content() != null; - Files.write(tempFile.toPath(), asset.content()); - - if (Desktop.isDesktopSupported()) { - Desktop.getDesktop().open(tempFile); - } else { - JOptionPane.showMessageDialog( - this, - "Cannot open media file automatically.\nIt has been saved to:\n" - + tempFile.getAbsolutePath(), - "Desktop Action Not Supported", - JOptionPane.WARNING_MESSAGE); - } - } catch (IOException e) { - JOptionPane.showMessageDialog( - this, - "Failed to create temporary file for preview: " + e.getMessage(), - "Error", - JOptionPane.ERROR_MESSAGE); - } - } - private String getFileExtension(String filename) { int i = filename.lastIndexOf('.'); if (i > 0 && i < filename.length() - 1) { diff --git a/src/main/java/io/github/pixelclover/uview/gui/AudioPlayerPanel.java b/src/main/java/io/github/pixelclover/uview/gui/AudioPlayerPanel.java index aa65fa9..6aa30c3 100644 --- a/src/main/java/io/github/pixelclover/uview/gui/AudioPlayerPanel.java +++ b/src/main/java/io/github/pixelclover/uview/gui/AudioPlayerPanel.java @@ -1,96 +1,200 @@ package io.github.pixelclover.uview.gui; -import java.awt.*; +import java.awt.BorderLayout; +import java.awt.FlowLayout; +import java.awt.event.ActionEvent; +import java.awt.event.ActionListener; import java.io.BufferedInputStream; import java.io.ByteArrayInputStream; import java.io.IOException; -import javax.sound.sampled.*; -import javax.swing.*; - -/** - * A panel with simple controls to play an audio clip. It implements {@link LineListener} to react - * to audio events, such as when playback stops, to update the UI correctly. - */ -public class AudioPlayerPanel extends JPanel implements LineListener { - - private final Clip clip; - private final JButton playStopButton; - - /** - * Constructs an AudioPlayerPanel. - * - * @param audioData The byte array containing the audio data. - * @throws UnsupportedAudioFileException if the audio format is not supported. - * @throws IOException if an I/O error occurs. - * @throws LineUnavailableException if a Line cannot be opened because it is unavailable. - */ +import javax.sound.sampled.AudioFormat; +import javax.sound.sampled.AudioInputStream; +import javax.sound.sampled.AudioSystem; +import javax.sound.sampled.DataLine; +import javax.sound.sampled.LineUnavailableException; +import javax.sound.sampled.SourceDataLine; +import javax.sound.sampled.UnsupportedAudioFileException; +import javax.swing.JButton; +import javax.swing.JLabel; +import javax.swing.JPanel; +import javax.swing.JSlider; +import javax.swing.SwingUtilities; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +public class AudioPlayerPanel extends JPanel implements ActionListener, Runnable { + private static final Logger LOGGER = LogManager.getLogger(AudioPlayerPanel.class); + + private byte[] audioData; + private AudioFormat audioFormat; + private final JSlider slider; + private final JButton playPauseButton; + private final JButton stopButton; + + private volatile boolean isPlaying = false; + private Thread playbackThread; + private SourceDataLine line; + private int framePosition = 0; + public AudioPlayerPanel(byte[] audioData) throws UnsupportedAudioFileException, IOException, LineUnavailableException { - super(new FlowLayout(FlowLayout.CENTER, 20, 20)); + super(new BorderLayout()); + + LOGGER.debug("Initializing AudioPlayerPanel..."); - JLabel statusLabel; try (AudioInputStream audioInputStream = AudioSystem.getAudioInputStream( new BufferedInputStream(new ByteArrayInputStream(audioData)))) { - this.clip = AudioSystem.getClip(); - this.clip.addLineListener(this); // Listen for events like STOP - this.clip.open(audioInputStream); - - AudioFormat format = audioInputStream.getFormat(); - String details = - String.format( - "%.1f kHz, %d-bit, %s", - format.getSampleRate() / 1000.0, - format.getSampleSizeInBits(), - format.getChannels() == 1 ? "Mono" : "Stereo"); - statusLabel = new JLabel(details); + AudioFormat baseFormat = audioInputStream.getFormat(); + AudioFormat decodedFormat = + new AudioFormat( + AudioFormat.Encoding.PCM_SIGNED, + baseFormat.getSampleRate(), + 16, + baseFormat.getChannels(), + baseFormat.getChannels() * 2, + baseFormat.getSampleRate(), + false); + try (AudioInputStream decodedAudioInputStream = + AudioSystem.getAudioInputStream(decodedFormat, audioInputStream)) { + this.audioData = decodedAudioInputStream.readAllBytes(); + this.audioFormat = decodedFormat; + } } - this.playStopButton = new JButton("Play"); - setupActionListener(); + String details = + String.format( + "%.1f kHz, %d-bit, %s", + audioFormat.getSampleRate() / 1000.0, + audioFormat.getSampleSizeInBits(), + audioFormat.getChannels() == 1 ? "Mono" : "Stereo"); + JLabel statusLabel = new JLabel(details); + LOGGER.debug("Audio format: {}", details); - add(playStopButton); - add(statusLabel); - } + playPauseButton = new JButton("Play"); + playPauseButton.addActionListener(this); - private void setupActionListener() { - playStopButton.addActionListener( + stopButton = new JButton("Stop"); + stopButton.addActionListener(this); + + slider = new JSlider(0, this.audioData.length); + slider.setValue(0); + slider.addChangeListener( e -> { - if (clip.isRunning()) { - clip.stop(); // The LineListener will handle the UI update. - } else { - clip.start(); - playStopButton.setText("Stop"); + if (!slider.getValueIsAdjusting()) { + seek(slider.getValue()); } }); + + JPanel controlsPanel = new JPanel(new FlowLayout(FlowLayout.CENTER, 20, 20)); + controlsPanel.add(playPauseButton); + controlsPanel.add(stopButton); + + add(controlsPanel, BorderLayout.CENTER); + add(slider, BorderLayout.NORTH); + add(statusLabel, BorderLayout.SOUTH); + + LOGGER.debug("AudioPlayerPanel initialized successfully."); } - /** - * Closes the audio clip to release system resources. This should be called when the panel is no - * longer needed. - */ - public void close() { - if (clip != null) { - clip.close(); + @Override + public void actionPerformed(ActionEvent e) { + if (e.getSource() == playPauseButton) { + if (isPlaying) { + pause(); + } else { + play(); + } + } else if (e.getSource() == stopButton) { + stop(); + } + } + + private void play() { + if (playbackThread == null || !playbackThread.isAlive()) { + playbackThread = new Thread(this); + playbackThread.start(); + } + isPlaying = true; + playPauseButton.setText("Pause"); + } + + private void pause() { + isPlaying = false; + playPauseButton.setText("Play"); + if (line != null) { + line.stop(); + } + } + + private void stop() { + isPlaying = false; + playPauseButton.setText("Play"); + if (playbackThread != null) { + playbackThread.interrupt(); + } + framePosition = 0; + slider.setValue(0); + } + + private void seek(int position) { + framePosition = position; + if (isPlaying) { + if (playbackThread != null) { + playbackThread.interrupt(); + } + play(); + } else { + slider.setValue(position); } } - /** - * Handles events from the audio line. This method is called when the clip's status changes (e.g., - * starts, stops). - * - * @param event The LineEvent that occurred. - */ @Override - public void update(LineEvent event) { - // This event is fired when playback finishes naturally OR is manually stopped. - if (event.getType() == LineEvent.Type.STOP) { - // All UI updates must happen on the Swing Event Dispatch Thread (EDT). - SwingUtilities.invokeLater( - () -> { - playStopButton.setText("Play"); - clip.setFramePosition(0); // Always rewind the clip when it stops. - }); + public void run() { + try { + DataLine.Info info = new DataLine.Info(SourceDataLine.class, audioFormat); + line = (SourceDataLine) AudioSystem.getLine(info); + line.open(audioFormat); + line.start(); + + int bufferSize = (int) audioFormat.getSampleRate() * audioFormat.getFrameSize(); + byte[] buffer = new byte[bufferSize]; + int bytesRead = 0; + + try (ByteArrayInputStream bis = new ByteArrayInputStream(audioData)) { + bis.skip(framePosition); + while (isPlaying && (bytesRead = bis.read(buffer, 0, buffer.length)) != -1) { + line.write(buffer, 0, bytesRead); + framePosition += bytesRead; + final int pos = framePosition; + SwingUtilities.invokeLater(() -> slider.setValue(pos)); + } + } + + line.drain(); + line.close(); + if (isPlaying) { + SwingUtilities.invokeLater( + () -> { + slider.setValue(0); + playPauseButton.setText("Play"); + }); + framePosition = 0; + isPlaying = false; + } + + } catch (LineUnavailableException | IOException e) { + LOGGER.error("Error during audio playback", e); + } finally { + if (line != null) { + line.close(); + } + isPlaying = false; + SwingUtilities.invokeLater(() -> playPauseButton.setText("Play")); } } + + public void close() { + stop(); + } } diff --git a/src/main/java/io/github/pixelclover/uview/gui/ButtonTabComponent.java b/src/main/java/io/github/pixelclover/uview/gui/ButtonTabComponent.java index 6b2f0a9..23513d5 100644 --- a/src/main/java/io/github/pixelclover/uview/gui/ButtonTabComponent.java +++ b/src/main/java/io/github/pixelclover/uview/gui/ButtonTabComponent.java @@ -1,11 +1,21 @@ package io.github.pixelclover.uview.gui; -import java.awt.*; +import java.awt.BasicStroke; +import java.awt.Color; +import java.awt.Component; +import java.awt.Dimension; +import java.awt.FlowLayout; +import java.awt.Graphics; +import java.awt.Graphics2D; import java.awt.event.ActionEvent; import java.awt.event.ActionListener; import java.awt.event.MouseAdapter; import java.awt.event.MouseEvent; -import javax.swing.*; +import javax.swing.BorderFactory; +import javax.swing.JButton; +import javax.swing.JLabel; +import javax.swing.JPanel; +import javax.swing.JTabbedPane; import javax.swing.plaf.basic.BasicButtonUI; /** A component to be used as a tab in a JTabbedPane. It contains a label and a close button. */ diff --git a/src/main/java/io/github/pixelclover/uview/gui/FileTypeTreeCellRenderer.java b/src/main/java/io/github/pixelclover/uview/gui/FileTypeTreeCellRenderer.java index e40ab89..9cbc707 100644 --- a/src/main/java/io/github/pixelclover/uview/gui/FileTypeTreeCellRenderer.java +++ b/src/main/java/io/github/pixelclover/uview/gui/FileTypeTreeCellRenderer.java @@ -1,8 +1,8 @@ package io.github.pixelclover.uview.gui; import io.github.pixelclover.uview.gui.tree.TreeEntry; -import java.awt.*; -import javax.swing.*; +import java.awt.Component; +import javax.swing.JTree; import javax.swing.tree.DefaultMutableTreeNode; import javax.swing.tree.DefaultTreeCellRenderer; diff --git a/src/main/java/io/github/pixelclover/uview/gui/FontManager.java b/src/main/java/io/github/pixelclover/uview/gui/FontManager.java index fd97cf9..4b9074a 100644 --- a/src/main/java/io/github/pixelclover/uview/gui/FontManager.java +++ b/src/main/java/io/github/pixelclover/uview/gui/FontManager.java @@ -1,6 +1,9 @@ package io.github.pixelclover.uview.gui; -import java.awt.*; +import java.awt.Font; +import java.awt.FontFormatException; +import java.awt.GraphicsEnvironment; +import java.io.BufferedInputStream; import java.io.IOException; import java.io.InputStream; import org.apache.logging.log4j.LogManager; @@ -29,7 +32,7 @@ public static void loadAndRegisterFonts() { LOGGER.error("Custom font not found at resource path: {}", path); continue; } - Font customFont = Font.createFont(Font.TRUETYPE_FONT, is); + Font customFont = Font.createFont(Font.TRUETYPE_FONT, new BufferedInputStream(is)); GraphicsEnvironment.getLocalGraphicsEnvironment().registerFont(customFont); LOGGER.info("Successfully registered font: {}", customFont.getFontName()); } catch (IOException | FontFormatException e) { diff --git a/src/main/java/io/github/pixelclover/uview/gui/IconManager.java b/src/main/java/io/github/pixelclover/uview/gui/IconManager.java index bc0c27a..d982880 100644 --- a/src/main/java/io/github/pixelclover/uview/gui/IconManager.java +++ b/src/main/java/io/github/pixelclover/uview/gui/IconManager.java @@ -3,7 +3,7 @@ import com.formdev.flatlaf.extras.FlatSVGIcon; import java.util.HashMap; import java.util.Map; -import javax.swing.*; +import javax.swing.Icon; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; diff --git a/src/main/java/io/github/pixelclover/uview/gui/MainWindow.java b/src/main/java/io/github/pixelclover/uview/gui/MainWindow.java index 097268d..65d719e 100644 --- a/src/main/java/io/github/pixelclover/uview/gui/MainWindow.java +++ b/src/main/java/io/github/pixelclover/uview/gui/MainWindow.java @@ -1,27 +1,53 @@ package io.github.pixelclover.uview.gui; -import com.formdev.flatlaf.FlatDarkLaf; -import com.formdev.flatlaf.FlatLightLaf; +import com.formdev.flatlaf.FlatDarculaLaf; +import com.formdev.flatlaf.FlatIntelliJLaf; import com.formdev.flatlaf.extras.FlatSVGIcon; -import io.github.pixelclover.uview.App; +import com.formdev.flatlaf.util.SystemInfo; import io.github.pixelclover.uview.core.PackageManager; import io.github.pixelclover.uview.core.SettingsManager; import io.github.pixelclover.uview.model.UnityAsset; -import java.awt.*; +import java.awt.BorderLayout; +import java.awt.CardLayout; +import java.awt.Component; +import java.awt.Cursor; +import java.awt.Desktop; +import java.awt.Dimension; +import java.awt.Font; import java.awt.datatransfer.DataFlavor; import java.awt.datatransfer.Transferable; -import java.awt.event.MouseAdapter; -import java.awt.event.MouseEvent; import java.awt.event.WindowAdapter; import java.awt.event.WindowEvent; import java.io.File; -import java.io.IOException; -import java.io.InputStream; import java.nio.file.Path; -import java.util.*; +import java.util.Enumeration; +import java.util.HashMap; import java.util.List; -import javax.swing.*; +import java.util.Map; +import javax.swing.BorderFactory; +import javax.swing.Box; +import javax.swing.BoxLayout; +import javax.swing.ButtonGroup; +import javax.swing.Icon; +import javax.swing.JButton; +import javax.swing.JDialog; +import javax.swing.JFileChooser; +import javax.swing.JFrame; +import javax.swing.JLabel; +import javax.swing.JMenu; +import javax.swing.JMenuBar; +import javax.swing.JMenuItem; +import javax.swing.JOptionPane; +import javax.swing.JPanel; +import javax.swing.JRadioButtonMenuItem; +import javax.swing.JSeparator; +import javax.swing.JTabbedPane; +import javax.swing.JToolBar; +import javax.swing.SwingUtilities; +import javax.swing.SwingWorker; import javax.swing.Timer; +import javax.swing.TransferHandler; +import javax.swing.UIManager; import javax.swing.event.MenuEvent; import javax.swing.event.MenuListener; import javax.swing.filechooser.FileNameExtensionFilter; @@ -35,6 +61,10 @@ public class MainWindow extends JFrame { private final SettingsManager settingsManager = new SettingsManager(); private final JTabbedPane tabbedPane; + private final JPanel contentPanel; + private final CardLayout cardLayout; + private static final String WELCOME_PANEL = "WelcomePanel"; + private static final String TABBED_PANE = "TabbedPane"; private final Map openAssetViewers = new HashMap<>(); private final Map openMetaEditors = new HashMap<>(); @@ -48,6 +78,8 @@ public class MainWindow extends JFrame { private JLabel fileCountLabel; private JLabel packageSizeLabel; private JLabel memoryUsageLabel; + private JButton saveButton; + private JButton extractAllButton; /** Constructs the main application window. */ public MainWindow() { @@ -67,35 +99,62 @@ public void windowClosing(WindowEvent e) { }); setJMenuBar(createMenuBar()); + add(createToolBar(), BorderLayout.NORTH); + + cardLayout = new CardLayout(); + contentPanel = new JPanel(cardLayout); + + WelcomePanel welcomePanel = new WelcomePanel(this::newPackage, this::openFile); + contentPanel.add(welcomePanel, WELCOME_PANEL); tabbedPane = new JTabbedPane(); tabbedPane.addChangeListener(e -> updateState()); + contentPanel.add(tabbedPane, TABBED_PANE); setTransferHandler(new FileDropHandler()); - add(tabbedPane, BorderLayout.CENTER); + add(contentPanel, BorderLayout.CENTER); add(createStatusBar(), BorderLayout.SOUTH); updateState(); } + private Icon getIcon(String name) { + return new FlatSVGIcon("icons/tabler_v1/" + name + ".svg"); + } + private static String formatSize(long bytes) { - if (bytes < 1024) return bytes + " B"; + if (bytes < 1024) { + return bytes + " B"; + } int exp = (int) (Math.log(bytes) / Math.log(1024)); char pre = "KMGTPE".charAt(exp - 1); return String.format("%.1f %sB", bytes / Math.pow(1024, exp), pre); } private void loadAndApplySettings() { - applyTheme(settingsManager.isDarkTheme()); + applyTheme(settingsManager.getTheme(), false); updateUiFont(settingsManager.getFontFamily(), settingsManager.getFontSize(), false); } - private void applyTheme(boolean useDarkTheme) { + private void applyTheme(String theme, boolean save) { try { - if (useDarkTheme) { - UIManager.setLookAndFeel(new FlatDarkLaf()); - } else { - UIManager.setLookAndFeel(new FlatLightLaf()); + if (SettingsManager.THEME_DARK.equals(theme)) { + UIManager.setLookAndFeel(new FlatDarculaLaf()); + } else if (SettingsManager.THEME_LIGHT.equals(theme)) { + UIManager.setLookAndFeel(new FlatIntelliJLaf()); + } else { // System theme + if (SystemInfo.isWindows) { + UIManager.setLookAndFeel(new FlatIntelliJLaf()); + } else if (SystemInfo.isMacOS) { + UIManager.setLookAndFeel(new FlatIntelliJLaf()); + } else if (SystemInfo.isLinux) { + UIManager.setLookAndFeel(new FlatDarculaLaf()); + } else { + UIManager.setLookAndFeel(new FlatIntelliJLaf()); + } + } + if (save) { + settingsManager.setTheme(theme); } SwingUtilities.updateComponentTreeUI(this); } catch (Exception ex) { @@ -104,12 +163,6 @@ private void applyTheme(boolean useDarkTheme) { } } - private void toggleTheme() { - boolean newIsDark = !settingsManager.isDarkTheme(); - settingsManager.setDarkTheme(newIsDark); - applyTheme(newIsDark); - } - private void updateUiFont(String family, int size, boolean save) { if (size < 10 || size > 24) { return; // Clamp size @@ -141,11 +194,11 @@ private JMenuBar createMenuBar() { JMenu fileMenu = new JMenu("File"); menuBar.add(fileMenu); - JMenuItem newMenuItem = new JMenuItem("New Package"); + JMenuItem newMenuItem = new JMenuItem("New Package", getIcon("file-plus")); newMenuItem.addActionListener(e -> newPackage()); fileMenu.add(newMenuItem); - JMenuItem openMenuItem = new JMenuItem("Open..."); + JMenuItem openMenuItem = new JMenuItem("Open...", getIcon("folder-open")); openMenuItem.addActionListener(e -> openFile()); fileMenu.add(openMenuItem); @@ -169,7 +222,7 @@ public void menuCanceled(MenuEvent e) {} fileMenu.add(new JSeparator()); - saveMenuItem = new JMenuItem("Save"); + saveMenuItem = new JMenuItem("Save", getIcon("device-floppy")); saveMenuItem.addActionListener(e -> saveFile()); fileMenu.add(saveMenuItem); @@ -179,7 +232,7 @@ public void menuCanceled(MenuEvent e) {} fileMenu.add(new JSeparator()); - extractAllMenuItem = new JMenuItem("Extract All..."); + extractAllMenuItem = new JMenuItem("Extract All...", getIcon("file_archive")); extractAllMenuItem.addActionListener(e -> extractAll()); fileMenu.add(extractAllMenuItem); @@ -192,9 +245,22 @@ public void menuCanceled(MenuEvent e) {} JMenu settingsMenu = new JMenu("Settings"); menuBar.add(settingsMenu); - JMenuItem toggleThemeItem = new JMenuItem("Toggle Light/Dark Theme"); - toggleThemeItem.addActionListener(e -> toggleTheme()); - settingsMenu.add(toggleThemeItem); + JMenu themeMenu = new JMenu("Theme"); + settingsMenu.add(themeMenu); + + ButtonGroup themeGroup = new ButtonGroup(); + String currentTheme = settingsManager.getTheme(); + + String[] themes = { + SettingsManager.THEME_LIGHT, SettingsManager.THEME_DARK, SettingsManager.THEME_SYSTEM + }; + for (String theme : themes) { + JRadioButtonMenuItem themeItem = new JRadioButtonMenuItem(theme); + themeItem.setSelected(theme.equals(currentTheme)); + themeItem.addActionListener(e -> applyTheme(theme, true)); + themeGroup.add(themeItem); + themeMenu.add(themeItem); + } settingsMenu.addSeparator(); @@ -241,7 +307,8 @@ public void menuCanceled(MenuEvent e) {} settingsManager.resetUiSettings(); JOptionPane.showMessageDialog( this, - "UI settings have been reset.\nPlease restart the application for all changes to take effect.", + "UI settings have been reset.\nPlease restart the application for all changes to take" + + " effect.", "Restart Required", JOptionPane.INFORMATION_MESSAGE); }); @@ -256,6 +323,36 @@ public void menuCanceled(MenuEvent e) {} return menuBar; } + private JToolBar createToolBar() { + JToolBar toolBar = new JToolBar(); + toolBar.setFloatable(false); + toolBar.setRollover(true); + + JButton newButton = new JButton(getIcon("file-plus")); + newButton.setToolTipText("New Package"); + newButton.addActionListener(e -> newPackage()); + toolBar.add(newButton); + + JButton openButton = new JButton(getIcon("folder-open")); + openButton.setToolTipText("Open..."); + openButton.addActionListener(e -> openFile()); + toolBar.add(openButton); + + saveButton = new JButton(getIcon("device-floppy")); + saveButton.setToolTipText("Save"); + saveButton.addActionListener(e -> saveFile()); + toolBar.add(saveButton); + + toolBar.addSeparator(); + + extractAllButton = new JButton(getIcon("file_archive")); + extractAllButton.setToolTipText("Extract All..."); + extractAllButton.addActionListener(e -> extractAll()); + toolBar.add(extractAllButton); + + return toolBar; + } + /** * Shows a viewer for the specified asset. This method manages open viewers to prevent duplicates. * If a viewer for the asset is already open, it brings it to the front. @@ -345,48 +442,8 @@ private void updateMemoryUsage() { } private void showAboutDialog() { - String version = getAppVersion(); - String title = "About UView"; - String message = - "" - + "

UView

" - + "

Version: " - + version - + "

" - + "

A desktop tool for viewing and modifying Unity packages.

" - + "

GitHub: https://github.com/habedi/uview

" - + ""; - - java.net.URL iconUrl = App.class.getResource("/logo.svg"); - Icon aboutIcon = (iconUrl != null) ? new FlatSVGIcon(iconUrl).derive(64, 64) : null; - - JLabel messageLabel = new JLabel(message); - messageLabel.addMouseListener( - new MouseAdapter() { - public void mouseClicked(MouseEvent e) { - try { - Desktop.getDesktop().browse(new java.net.URI("https://github.com/habedi/uview")); - } catch (Exception ex) { - // Ignore - } - } - }); - - JOptionPane.showMessageDialog( - this, messageLabel, title, JOptionPane.INFORMATION_MESSAGE, aboutIcon); - } - - private String getAppVersion() { - try (InputStream is = - App.class.getResourceAsStream( - "/META-INF/maven/io.github.pixelclover/uview/pom.properties")) { - if (is == null) return "N/A"; - Properties props = new Properties(); - props.load(is); - return props.getProperty("version", "N/A"); - } catch (IOException e) { - return "N/A"; - } + AboutDialog aboutDialog = new AboutDialog(this); + aboutDialog.setVisible(true); } private void populateRecentFilesMenu() { @@ -467,7 +524,9 @@ private PackageViewPanel getCurrentPanel() { private void saveFile() { PackageViewPanel currentPanel = getCurrentPanel(); - if (currentPanel == null) return; + if (currentPanel == null) { + return; + } if (currentPanel.getPackageFile() == null) { saveFileAs(); } else { @@ -477,7 +536,9 @@ private void saveFile() { private void saveFileAs() { PackageViewPanel currentPanel = getCurrentPanel(); - if (currentPanel == null) return; + if (currentPanel == null) { + return; + } JFileChooser chooser = createFileChooser("Save Unity Package As..."); chooser.setFileFilter(new FileNameExtensionFilter("Unity Package", "unitypackage")); @@ -560,7 +621,9 @@ protected void done() { private void extractAll() { PackageViewPanel currentPanel = getCurrentPanel(); - if (currentPanel == null) return; + if (currentPanel == null) { + return; + } JFileChooser chooser = createFileChooser("Select Directory to Extract All Assets"); chooser.setFileSelectionMode(JFileChooser.DIRECTORIES_ONLY); @@ -611,11 +674,14 @@ void updateState() { boolean hasPanel = currentPanel != null; saveMenuItem.setEnabled(hasPanel && currentPanel.getPackageManager().isModified()); + saveButton.setEnabled(hasPanel && currentPanel.getPackageManager().isModified()); saveAsMenuItem.setEnabled(hasPanel); closeMenuItem.setEnabled(hasPanel); extractAllMenuItem.setEnabled(hasPanel); + extractAllButton.setEnabled(hasPanel); if (hasPanel) { + cardLayout.show(contentPanel, TABBED_PANE); int selectedIndex = tabbedPane.getSelectedIndex(); tabbedPane.setTitleAt(selectedIndex, currentPanel.getTabTitle()); @@ -633,6 +699,7 @@ void updateState() { packageSizeLabel.setText(""); } } else { + cardLayout.show(contentPanel, WELCOME_PANEL); setTitle("UView"); statusLabel.setText("Ready"); fileCountLabel.setText(""); diff --git a/src/main/java/io/github/pixelclover/uview/gui/MetaEditorFrame.java b/src/main/java/io/github/pixelclover/uview/gui/MetaEditorFrame.java index dac3c8a..2acec41 100644 --- a/src/main/java/io/github/pixelclover/uview/gui/MetaEditorFrame.java +++ b/src/main/java/io/github/pixelclover/uview/gui/MetaEditorFrame.java @@ -2,9 +2,13 @@ import io.github.pixelclover.uview.core.PackageManager; import io.github.pixelclover.uview.model.UnityAsset; -import java.awt.*; +import java.awt.BorderLayout; +import java.awt.FlowLayout; import java.nio.charset.StandardCharsets; -import javax.swing.*; +import javax.swing.JButton; +import javax.swing.JDialog; +import javax.swing.JFrame; +import javax.swing.JPanel; import org.fife.ui.rsyntaxtextarea.RSyntaxTextArea; import org.fife.ui.rsyntaxtextarea.SyntaxConstants; import org.fife.ui.rsyntaxtextarea.Theme; @@ -46,28 +50,28 @@ public MetaEditorFrame( } private RSyntaxTextArea createTextArea() { - RSyntaxTextArea rSyntaxTextArea = new RSyntaxTextArea(); - rSyntaxTextArea.setEditable(true); + RSyntaxTextArea rsyntaxTextArea = new RSyntaxTextArea(); + rsyntaxTextArea.setEditable(true); if (asset.metaContent() != null) { - rSyntaxTextArea.setText(new String(asset.metaContent(), StandardCharsets.UTF_8)); + rsyntaxTextArea.setText(new String(asset.metaContent(), StandardCharsets.UTF_8)); } else { String defaultMeta = String.format("fileFormatVersion: 2\nguid: %s\n", asset.guid()); - rSyntaxTextArea.setText(defaultMeta); + rsyntaxTextArea.setText(defaultMeta); } - rSyntaxTextArea.setCaretPosition(0); + rsyntaxTextArea.setCaretPosition(0); // Apply syntax highlighting and theme - rSyntaxTextArea.setSyntaxEditingStyle(SyntaxConstants.SYNTAX_STYLE_YAML); + rsyntaxTextArea.setSyntaxEditingStyle(SyntaxConstants.SYNTAX_STYLE_YAML); try { Theme theme = Theme.load( getClass().getResourceAsStream("/org/fife/ui/rsyntaxtextarea/themes/dark.xml")); - theme.apply(rSyntaxTextArea); + theme.apply(rsyntaxTextArea); } catch (Exception e) { // Ignore, fallback to default theme } - return rSyntaxTextArea; + return rsyntaxTextArea; } private JPanel createButtonPanel() { diff --git a/src/main/java/io/github/pixelclover/uview/gui/PackageViewPanel.java b/src/main/java/io/github/pixelclover/uview/gui/PackageViewPanel.java index 6c59b38..a68b9a1 100644 --- a/src/main/java/io/github/pixelclover/uview/gui/PackageViewPanel.java +++ b/src/main/java/io/github/pixelclover/uview/gui/PackageViewPanel.java @@ -6,14 +6,30 @@ import io.github.pixelclover.uview.gui.tree.TreeEntry; import io.github.pixelclover.uview.io.PackageIO; import io.github.pixelclover.uview.model.UnityAsset; -import java.awt.*; +import java.awt.BorderLayout; +import java.awt.Cursor; +import java.awt.Desktop; +import java.awt.Dimension; import java.awt.event.MouseAdapter; import java.awt.event.MouseEvent; import java.io.File; import java.nio.file.Path; import java.util.Collection; import java.util.List; -import javax.swing.*; +import javax.swing.BorderFactory; +import javax.swing.JFileChooser; +import javax.swing.JFrame; +import javax.swing.JMenuItem; +import javax.swing.JOptionPane; +import javax.swing.JPanel; +import javax.swing.JPopupMenu; +import javax.swing.JScrollPane; +import javax.swing.JSeparator; +import javax.swing.JTextField; +import javax.swing.JTree; +import javax.swing.SwingUtilities; +import javax.swing.SwingWorker; +import javax.swing.Timer; import javax.swing.event.DocumentEvent; import javax.swing.event.DocumentListener; import javax.swing.tree.DefaultMutableTreeNode; @@ -140,7 +156,6 @@ private void filterTree() { private JPopupMenu createPopupMenu() { JPopupMenu popup = new JPopupMenu(); - TreePath selectionPath = tree.getSelectionPath(); JMenuItem viewMenuItem = new JMenuItem("View"); viewMenuItem.addActionListener(e -> handleDoubleClick()); @@ -172,6 +187,7 @@ private JPopupMenu createPopupMenu() { removeMenuItem.setEnabled(false); extractSelectedMenuItem.setEnabled(false); + TreePath selectionPath = tree.getSelectionPath(); if (selectionPath != null) { DefaultMutableTreeNode selectedNode = (DefaultMutableTreeNode) selectionPath.getLastPathComponent(); diff --git a/src/main/java/io/github/pixelclover/uview/gui/PdfViewerPanel.java b/src/main/java/io/github/pixelclover/uview/gui/PdfViewerPanel.java index c7a9e5f..838ad16 100644 --- a/src/main/java/io/github/pixelclover/uview/gui/PdfViewerPanel.java +++ b/src/main/java/io/github/pixelclover/uview/gui/PdfViewerPanel.java @@ -1,11 +1,20 @@ package io.github.pixelclover.uview.gui; -import java.awt.*; +import java.awt.BorderLayout; +import java.awt.FlowLayout; import java.awt.event.KeyAdapter; import java.awt.event.KeyEvent; import java.awt.image.BufferedImage; import java.io.IOException; -import javax.swing.*; +import javax.swing.BoxLayout; +import javax.swing.ImageIcon; +import javax.swing.JButton; +import javax.swing.JLabel; +import javax.swing.JOptionPane; +import javax.swing.JPanel; +import javax.swing.JScrollPane; +import javax.swing.JTextField; +import javax.swing.SwingUtilities; import org.apache.pdfbox.Loader; import org.apache.pdfbox.pdmodel.PDDocument; import org.apache.pdfbox.rendering.PDFRenderer; diff --git a/src/main/java/io/github/pixelclover/uview/gui/SyntaxTextPanel.java b/src/main/java/io/github/pixelclover/uview/gui/SyntaxTextPanel.java index 86aa218..27ed880 100644 --- a/src/main/java/io/github/pixelclover/uview/gui/SyntaxTextPanel.java +++ b/src/main/java/io/github/pixelclover/uview/gui/SyntaxTextPanel.java @@ -1,13 +1,19 @@ package io.github.pixelclover.uview.gui; import io.github.pixelclover.uview.model.UnityAsset; -import java.awt.*; +import java.awt.BorderLayout; +import java.awt.Color; import java.nio.charset.StandardCharsets; import java.util.function.Consumer; -import javax.swing.*; +import javax.swing.JPanel; import javax.swing.event.DocumentEvent; import javax.swing.event.DocumentListener; -import org.fife.ui.rsyntaxtextarea.*; +import org.fife.ui.rsyntaxtextarea.RSyntaxTextArea; +import org.fife.ui.rsyntaxtextarea.Style; +import org.fife.ui.rsyntaxtextarea.SyntaxConstants; +import org.fife.ui.rsyntaxtextarea.SyntaxScheme; +import org.fife.ui.rsyntaxtextarea.Theme; +import org.fife.ui.rsyntaxtextarea.Token; import org.fife.ui.rtextarea.RTextScrollPane; /** diff --git a/src/main/java/io/github/pixelclover/uview/gui/VideoPlayerPanel.java b/src/main/java/io/github/pixelclover/uview/gui/VideoPlayerPanel.java new file mode 100644 index 0000000..f7ae9a1 --- /dev/null +++ b/src/main/java/io/github/pixelclover/uview/gui/VideoPlayerPanel.java @@ -0,0 +1,142 @@ +package io.github.pixelclover.uview.gui; + +import io.github.pixelclover.uview.model.UnityAsset; +import java.awt.BorderLayout; +import java.nio.file.Path; +import javafx.application.Platform; +import javafx.embed.swing.JFXPanel; +import javafx.geometry.Insets; +import javafx.geometry.Pos; +import javafx.scene.Scene; +import javafx.scene.control.Button; +import javafx.scene.control.Label; +import javafx.scene.control.Slider; +import javafx.scene.layout.BorderPane; +import javafx.scene.layout.HBox; +import javafx.scene.layout.Priority; +import javafx.scene.media.Media; +import javafx.scene.media.MediaPlayer; +import javafx.scene.media.MediaView; +import javafx.util.Duration; +import javax.swing.JPanel; + +/** A panel for playing video files using JavaFX media components. */ +public class VideoPlayerPanel extends JPanel { + + private final JFXPanel jfxPanel = new JFXPanel(); + private MediaPlayer mediaPlayer; + + /** + * Constructs a new video player panel. + * + * @param asset The Unity asset representing the video. + * @param assetPath The path to the video file on disk. + */ + public VideoPlayerPanel(UnityAsset asset, Path assetPath) { + setLayout(new BorderLayout()); + add(jfxPanel, BorderLayout.CENTER); + + Platform.setImplicitExit(false); + Platform.runLater( + () -> { + try { + Media media = new Media(assetPath.toUri().toString()); + mediaPlayer = new MediaPlayer(media); + mediaPlayer.setAutoPlay(true); + + MediaView mediaView = new MediaView(mediaPlayer); + mediaView.setPreserveRatio(true); + + BorderPane root = new BorderPane(); + root.setCenter(mediaView); + root.setBottom(createControls()); + + Scene scene = new Scene(root); + mediaView.fitWidthProperty().bind(scene.widthProperty()); + mediaView.fitHeightProperty().bind(scene.heightProperty().subtract(50)); + jfxPanel.setScene(scene); + } catch (Exception e) { + // Handle exceptions, e.g., by showing an error message + } + }); + } + + private HBox createControls() { + HBox controls = new HBox(10); + controls.setAlignment(Pos.CENTER); + controls.setPadding(new Insets(10)); + + Button playPauseButton = new Button("Pause"); + playPauseButton.setOnAction( + e -> { + if (mediaPlayer.getStatus() == MediaPlayer.Status.PLAYING) { + mediaPlayer.pause(); + playPauseButton.setText("Play"); + } else { + mediaPlayer.play(); + playPauseButton.setText("Pause"); + } + }); + + Label timeLabel = new Label(); + Slider timeSlider = new Slider(); + HBox.setHgrow(timeSlider, Priority.ALWAYS); + + Slider volumeSlider = new Slider(0, 1, 1); + volumeSlider.setPrefWidth(100); + volumeSlider + .valueProperty() + .addListener((obs, oldVal, newVal) -> mediaPlayer.setVolume(newVal.doubleValue())); + + controls.getChildren().addAll(playPauseButton, timeSlider, timeLabel, volumeSlider); + + mediaPlayer + .currentTimeProperty() + .addListener( + (obs, oldTime, newTime) -> { + if (!timeSlider.isValueChanging()) { + timeSlider.setValue(newTime.toSeconds()); + } + timeLabel.setText( + formatDuration(newTime) + " / " + formatDuration(mediaPlayer.getTotalDuration())); + }); + + mediaPlayer.setOnReady( + () -> { + timeSlider.setMax(mediaPlayer.getTotalDuration().toSeconds()); + timeLabel.setText("00:00 / " + formatDuration(mediaPlayer.getTotalDuration())); + }); + + timeSlider + .valueProperty() + .addListener( + (obs, oldValue, newValue) -> { + if (timeSlider.isValueChanging()) { + mediaPlayer.seek(Duration.seconds(newValue.doubleValue())); + } + }); + + return controls; + } + + private String formatDuration(Duration duration) { + if (duration == null || duration.isUnknown()) { + return "00:00"; + } + long seconds = (long) duration.toSeconds(); + long minutes = seconds / 60; + long secs = seconds % 60; + return String.format("%02d:%02d", minutes, secs); + } + + /** Stops the media player and releases resources. */ + public void stop() { + if (mediaPlayer != null) { + Platform.runLater( + () -> { + mediaPlayer.stop(); + mediaPlayer.dispose(); + }); + } + } +} diff --git a/src/main/java/io/github/pixelclover/uview/gui/WelcomePanel.java b/src/main/java/io/github/pixelclover/uview/gui/WelcomePanel.java new file mode 100644 index 0000000..2cf06a6 --- /dev/null +++ b/src/main/java/io/github/pixelclover/uview/gui/WelcomePanel.java @@ -0,0 +1,65 @@ +package io.github.pixelclover.uview.gui; + +import com.formdev.flatlaf.extras.FlatSVGIcon; +import java.awt.BorderLayout; +import java.awt.Dimension; +import java.awt.FlowLayout; +import java.awt.Font; +import javax.swing.Box; +import javax.swing.BoxLayout; +import javax.swing.JButton; +import javax.swing.JLabel; +import javax.swing.JPanel; +import javax.swing.border.EmptyBorder; + +/** + * A panel to be displayed when no tabs are open, welcoming the user and providing quick actions. + */ +public class WelcomePanel extends JPanel { + + /** + * Constructs a new welcome panel. + * + * @param newPackageAction The action to run when the "New Package" button is clicked. + * @param openFileAction The action to run when the "Open..." button is clicked. + */ + public WelcomePanel(Runnable newPackageAction, Runnable openFileAction) { + setLayout(new BorderLayout()); + setBorder(new EmptyBorder(20, 20, 20, 20)); + + // Logo and Title + JPanel headerPanel = new JPanel(); + headerPanel.setLayout(new BoxLayout(headerPanel, BoxLayout.Y_AXIS)); + headerPanel.setAlignmentX(CENTER_ALIGNMENT); + + FlatSVGIcon logo = new FlatSVGIcon("logo.svg", 128, 128); + JLabel logoLabel = new JLabel(logo); + logoLabel.setAlignmentX(CENTER_ALIGNMENT); + headerPanel.add(logoLabel); + + headerPanel.add(Box.createRigidArea(new Dimension(0, 20))); + + JLabel titleLabel = new JLabel("Welcome to UView"); + titleLabel.setFont(new Font(titleLabel.getFont().getName(), Font.BOLD, 24)); + titleLabel.setAlignmentX(CENTER_ALIGNMENT); + headerPanel.add(titleLabel); + + JLabel subtitleLabel = new JLabel("A tool for viewing and modifying Unity packages."); + subtitleLabel.setAlignmentX(CENTER_ALIGNMENT); + headerPanel.add(subtitleLabel); + + add(headerPanel, BorderLayout.CENTER); + + // Action Buttons + JPanel actionPanel = new JPanel(new FlowLayout(FlowLayout.CENTER, 10, 10)); + JButton newButton = new JButton("New Package"); + newButton.addActionListener(e -> newPackageAction.run()); + actionPanel.add(newButton); + + JButton openButton = new JButton("Open..."); + openButton.addActionListener(e -> openFileAction.run()); + actionPanel.add(openButton); + + add(actionPanel, BorderLayout.SOUTH); + } +} diff --git a/src/main/java/io/github/pixelclover/uview/io/PackageIO.java b/src/main/java/io/github/pixelclover/uview/io/PackageIO.java index f377f35..24e59f9 100644 --- a/src/main/java/io/github/pixelclover/uview/io/PackageIO.java +++ b/src/main/java/io/github/pixelclover/uview/io/PackageIO.java @@ -2,7 +2,11 @@ import io.github.pixelclover.uview.model.UnityAsset; import io.github.pixelclover.uview.model.UnityPackage; -import java.io.*; +import java.io.BufferedOutputStream; +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.IOException; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.StandardCopyOption; diff --git a/src/main/resources/fonts/jetbrains/OFL.txt b/src/main/resources/fonts/jetbrains/OFL.txt index 8bee414..23a3dca 100644 --- a/src/main/resources/fonts/jetbrains/OFL.txt +++ b/src/main/resources/fonts/jetbrains/OFL.txt @@ -18,7 +18,7 @@ with others. The OFL allows the licensed fonts to be used, studied, modified and redistributed freely as long as they are not sold by themselves. The -fonts, including any derivative works, can be bundled, embedded, +fonts, including any derivative works, can be bundled, embedded, redistributed and/or sold with any software provided that any reserved names are not used by derivative works. The fonts and derivatives, however, cannot be released under any other type of license. The diff --git a/src/main/resources/icons/tabler_v1/device-floppy.svg b/src/main/resources/icons/tabler_v1/device-floppy.svg new file mode 100644 index 0000000..33d6f87 --- /dev/null +++ b/src/main/resources/icons/tabler_v1/device-floppy.svg @@ -0,0 +1 @@ + diff --git a/src/main/resources/icons/tabler_v1/file-plus.svg b/src/main/resources/icons/tabler_v1/file-plus.svg new file mode 100644 index 0000000..8005230 --- /dev/null +++ b/src/main/resources/icons/tabler_v1/file-plus.svg @@ -0,0 +1 @@ + diff --git a/src/main/resources/icons/tabler_v1/folder-open.svg b/src/main/resources/icons/tabler_v1/folder-open.svg new file mode 100644 index 0000000..6d6cd7f --- /dev/null +++ b/src/main/resources/icons/tabler_v1/folder-open.svg @@ -0,0 +1 @@ + diff --git a/src/test/java/io/github/pixelclover/uview/core/PackageManagerTest.java b/src/test/java/io/github/pixelclover/uview/core/PackageManagerTest.java index b53ebc4..78427c0 100644 --- a/src/test/java/io/github/pixelclover/uview/core/PackageManagerTest.java +++ b/src/test/java/io/github/pixelclover/uview/core/PackageManagerTest.java @@ -116,4 +116,69 @@ void getFilteredAssetsReturnsCorrectSubset() throws IOException { assertEquals( "Assets/Scripts/Player/PlayerController.cs", csResults.iterator().next().assetPath()); } + + @Test + void addAssetThrowsExceptionForLargeFile() throws IOException { + Path largeFile = tempDir.resolve("large-file.bin"); + Files.write(largeFile, new byte[20]); // 20 bytes + + // Create a new PackageManager with a tiny size limit for this test + PackageManager sizeLimitedManager = new PackageManager(new PackageIO(), 10); // 10 byte limit + + IOException e = + assertThrows( + IOException.class, + () -> sizeLimitedManager.addAsset(largeFile, "Assets/large-file.bin"), + "Should throw IOException for file exceeding max size."); + assertTrue( + e.getMessage().startsWith("File is too large"), + "Exception message should indicate file is too large."); + } + + @Test + void updateAssetContentUpdatesTheAsset() throws IOException { + String assetPath = "Assets/MyFile.txt"; + packageManager.addAsset(sourceFile, assetPath); + assertEquals("test", new String(packageManager.getAssets().iterator().next().content())); + + byte[] newContent = "updated content".getBytes(StandardCharsets.UTF_8); + packageManager.updateAssetContent(assetPath, newContent); + + assertTrue(packageManager.isModified()); + assertEquals(1, packageManager.getAssets().size()); + assertEquals( + "updated content", new String(packageManager.getAssets().iterator().next().content())); + } + + @Test + void updateAssetMetaMarksPackageAsModified() throws IOException { + String assetPath = "Assets/MyFile.txt"; + packageManager.addAsset(sourceFile, assetPath); + packageManager.savePackage(new File("dummy.unitypackage")); // Reset modified flag + assertFalse(packageManager.isModified()); + + byte[] newMetaContent = "new meta".getBytes(StandardCharsets.UTF_8); + packageManager.updateAssetMeta(assetPath, newMetaContent); + + assertTrue(packageManager.isModified()); + assertEquals( + "new meta", new String(packageManager.getAssets().iterator().next().metaContent())); + } + + @Test + void removeDirectoryMarksPackageAsModifiedAndRemovesAssets() throws IOException { + packageManager.addAsset(sourceFile, "Assets/MyDir/File1.txt"); + packageManager.addAsset(sourceFile2, "Assets/MyDir/SubDir/File2.txt"); + packageManager.addAsset(sourceFile, "Assets/Other/File3.txt"); + packageManager.savePackage(new File("dummy.unitypackage")); // Reset modified flag + assertFalse(packageManager.isModified()); + assertEquals(3, packageManager.getAssets().size()); + + packageManager.removeDirectory("Assets/MyDir"); + + assertTrue(packageManager.isModified()); + assertEquals(1, packageManager.getAssets().size()); + assertEquals( + "Assets/Other/File3.txt", packageManager.getAssets().iterator().next().assetPath()); + } } diff --git a/src/test/java/io/github/pixelclover/uview/gui/FontManagerTest.java b/src/test/java/io/github/pixelclover/uview/gui/FontManagerTest.java new file mode 100644 index 0000000..a1f2add --- /dev/null +++ b/src/test/java/io/github/pixelclover/uview/gui/FontManagerTest.java @@ -0,0 +1,21 @@ +package io.github.pixelclover.uview.gui; + +import static org.junit.jupiter.api.Assertions.*; + +import java.awt.GraphicsEnvironment; +import org.junit.jupiter.api.Assumptions; +import org.junit.jupiter.api.Test; + +class FontManagerTest { + + @Test + void loadAndRegisterFontsDoesNotThrowException() { + // This test can only run in a headful environment. + Assumptions.assumeFalse(GraphicsEnvironment.isHeadless()); + + // The test passes if this method executes without throwing any exceptions. + assertDoesNotThrow( + FontManager::loadAndRegisterFonts, + "FontManager.loadAndRegisterFonts() should not throw any exceptions."); + } +} diff --git a/test_assets/test_image.png b/test_assets/test_image.png new file mode 100644 index 0000000..065dbcd --- /dev/null +++ b/test_assets/test_image.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3a5fbe5cd6c8d733c0424bdbcd2f71d4261db94adaf96b1d3bd166091930d301 +size 89 diff --git a/test_assets/test_script.cs b/test_assets/test_script.cs new file mode 100644 index 0000000..ce06b7c --- /dev/null +++ b/test_assets/test_script.cs @@ -0,0 +1,4 @@ +// This is a dummy C# script for testing. +public class TestScript { + // Hello, World! +}