Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# koel/player guidelines

## Self-Explanatory Code
- Code should read on its own. If a piece of code needs a comment to be understood, that's a signal the code is wrong, not that the comment is needed — refactor it: extract a named helper, rename a variable to encode intent, lift a condition into a named flag, pull a block into a small function. Use a comment only when refactoring genuinely can't carry the intent (a hidden invariant, a workaround tied to a specific external bug, behaviour a reader would otherwise misjudge). Never write comments that narrate the next line, summarise the surrounding block, or restate what well-named identifiers already say.
- Don't use single-letter variable names. The only allowed ones are `i` / `j` for loop counters and `h` for the test harness. For everything else (callback params, destructured fields, lambda args, etc.) pick a name that says what it is.

## Commit and PR Messages
- Same rule applies to commit bodies and PR descriptions: short, focused, no prose that just restates the diff. The "what" is in the diff; only spell out the "why" when it's non-obvious.
- Never mention Claude Code in commits, PR descriptions, or any generated content. No "Generated with Claude Code" footers, no Co-Authored-By lines referencing Claude.
- When the implementation of a PR changes (e.g. during code review), update the PR title and description to reflect the current state.

## Tests
- Every change must be programmatically tested. Write a new test or update an existing test, then run the affected tests to make sure they pass.
- Don't run `flutter test` / `flutter analyze` on every edit — only before committing or when explicitly asked.
- Prefer behaviour tests that exercise a real round-trip (mocked HTTP client + state assertions) over literal-pin tests that just assert a hardcoded string against another hardcoded string.

## Comments on Existing Code
- The repo has a memory of past comment overuse. When editing code that has lengthy comments, take the opportunity to refactor them away if you can.
23 changes: 23 additions & 0 deletions lib/providers/album_provider.dart
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import 'dart:async';

import 'package:app/enums.dart';
import 'package:app/mixins/stream_subscriber.dart';
import 'package:app/models/models.dart';
import 'package:app/providers/artist_provider.dart';
import 'package:app/providers/auth_provider.dart';
import 'package:app/utils/api_request.dart';
import 'package:flutter/foundation.dart';
Expand All @@ -12,6 +15,9 @@ class AlbumProvider with ChangeNotifier, StreamSubscriber {
var _sortField = 'name';
var _sortOrder = SortOrder.asc;

static final _renamedController = StreamController<Album>.broadcast();
static final renamedStream = _renamedController.stream;

String get sortField => _sortField;
SortOrder get sortOrder => _sortOrder;

Expand All @@ -36,6 +42,19 @@ class AlbumProvider with ChangeNotifier, StreamSubscriber {

notifyListeners();
}));

subscribe(ArtistProvider.renamedStream.listen(_onArtistRenamed));
}

void _onArtistRenamed(Artist artist) {
var changed = false;
for (final album in _vault.values) {
if (album.artistId == artist.id && album.artistName != artist.name) {
album.artistName = artist.name;
changed = true;
}
}
if (changed) notifyListeners();
}

List<Album> byIds(List<dynamic> ids) {
Expand Down Expand Up @@ -135,12 +154,16 @@ class AlbumProvider with ChangeNotifier, StreamSubscriber {
'year': year,
});

final renamed = album.name != response['name'];

album
..name = response['name']
..year = response['year'] == null
? null
: int.parse(response['year'].toString());

notifyListeners();

if (renamed) _renamedController.add(album);
}
}
9 changes: 9 additions & 0 deletions lib/providers/artist_provider.dart
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import 'dart:async';

import 'package:app/enums.dart';
import 'package:app/mixins/stream_subscriber.dart';
import 'package:app/models/models.dart';
Expand All @@ -12,6 +14,9 @@ class ArtistProvider with ChangeNotifier, StreamSubscriber {
var _sortField = 'name';
var _sortOrder = SortOrder.asc;

static final _renamedController = StreamController<Artist>.broadcast();
static final renamedStream = _renamedController.stream;

String get sortField => _sortField;
SortOrder get sortOrder => _sortOrder;

Expand Down Expand Up @@ -130,8 +135,12 @@ class ArtistProvider with ChangeNotifier, StreamSubscriber {
'name': name,
});

final renamed = artist.name != response['name'];

artist.name = response['name'];

notifyListeners();

if (renamed) _renamedController.add(artist);
}
}
30 changes: 30 additions & 0 deletions lib/providers/playable_provider.dart
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,36 @@ class PlayableProvider with ChangeNotifier, StreamSubscriber {
_vault.clear();
notifyListeners();
}));

subscribe(AlbumProvider.renamedStream.listen(_onAlbumRenamed));
subscribe(ArtistProvider.renamedStream.listen(_onArtistRenamed));
}

void _onAlbumRenamed(Album album) {
var changed = false;
for (final song in _vault.values.whereType<Song>()) {
if (song.albumId == album.id && song.albumName != album.name) {
song.albumName = album.name;
changed = true;
}
}
if (changed) notifyListeners();
}

void _onArtistRenamed(Artist artist) {
var changed = false;
for (final song in _vault.values.whereType<Song>()) {
if (song.artistId == artist.id && song.artistName != artist.name) {
song.artistName = artist.name;
changed = true;
}
if (song.albumArtistId == artist.id &&
song.albumArtistName != artist.name) {
song.albumArtistName = artist.name;
changed = true;
}
}
if (changed) notifyListeners();
}

List<Playable> syncWithVault(dynamic _playables) {
Expand Down
10 changes: 6 additions & 4 deletions lib/ui/screens/album_details.dart
Original file line number Diff line number Diff line change
Expand Up @@ -51,9 +51,10 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> {

return Scaffold(
body: GradientDecoratedContainer(
child: FutureBuilder(
future: buildRequest(albumId),
builder: (_, AsyncSnapshot<List<Object>> snapshot) {
child: Consumer<AlbumProvider>(
builder: (_, __, ___) => FutureBuilder(
future: buildRequest(albumId),
builder: (_, AsyncSnapshot<List<Object>> snapshot) {
if (!snapshot.hasData ||
snapshot.connectionState == ConnectionState.active)
return const PlayableListScreenPlaceholder();
Expand Down Expand Up @@ -123,7 +124,8 @@ class _AlbumDetailsScreenState extends State<AlbumDetailsScreen> {
],
),
);
},
},
),
),
),
);
Expand Down
10 changes: 6 additions & 4 deletions lib/ui/screens/artist_details.dart
Original file line number Diff line number Diff line change
Expand Up @@ -43,9 +43,10 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {

return Scaffold(
body: GradientDecoratedContainer(
child: FutureBuilder(
future: buildRequest(artistId),
builder: (_, AsyncSnapshot<List<dynamic>> snapshot) {
child: Consumer<ArtistProvider>(
builder: (_, __, ___) => FutureBuilder(
future: buildRequest(artistId),
builder: (_, AsyncSnapshot<List<dynamic>> snapshot) {
if (!snapshot.hasData ||
snapshot.connectionState == ConnectionState.active)
return const PlayableListScreenPlaceholder();
Expand Down Expand Up @@ -131,7 +132,8 @@ class _ArtistDetailsScreenState extends State<ArtistDetailsScreen> {
),
),
);
},
},
),
),
),
);
Expand Down
76 changes: 40 additions & 36 deletions lib/ui/widgets/album_card.dart
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import 'package:app/models/models.dart';
import 'package:app/providers/album_provider.dart';
import 'package:app/router.dart';
import 'package:app/ui/screens/album_action_sheet.dart';
import 'package:app/ui/widgets/widgets.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';

class AlbumCard extends StatefulWidget {
final Album album;
Expand All @@ -24,43 +26,45 @@ class _AlbumCardState extends State<AlbumCard> {

@override
Widget build(BuildContext context) {
return GestureDetector(
onTapDown: (_) => setState(() => _opacity = 0.4),
onTapUp: (_) => setState(() => _opacity = 1.0),
onTapCancel: () => setState(() => _opacity = 1.0),
onTap: () => widget.router.gotoAlbumDetailsScreen(
context,
albumId: widget.album.id,
),
onLongPress: () => showAlbumActionSheet(context, album: widget.album),
behavior: HitTestBehavior.opaque,
child: AnimatedOpacity(
duration: const Duration(milliseconds: 100),
opacity: _opacity,
child: Column(
children: <Widget>[
AlbumArtistThumbnail.md(entity: widget.album, asHero: true),
const SizedBox(height: 12),
SizedBox(
width: _cardWidth,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
widget.album.name,
style: const TextStyle(fontWeight: FontWeight.bold),
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 6),
Text(
widget.album.artistName,
style: const TextStyle(color: Colors.white54),
overflow: TextOverflow.ellipsis,
),
],
return Consumer<AlbumProvider>(
builder: (_, __, ___) => GestureDetector(
onTapDown: (_) => setState(() => _opacity = 0.4),
onTapUp: (_) => setState(() => _opacity = 1.0),
onTapCancel: () => setState(() => _opacity = 1.0),
onTap: () => widget.router.gotoAlbumDetailsScreen(
context,
albumId: widget.album.id,
),
onLongPress: () => showAlbumActionSheet(context, album: widget.album),
behavior: HitTestBehavior.opaque,
child: AnimatedOpacity(
duration: const Duration(milliseconds: 100),
opacity: _opacity,
child: Column(
children: <Widget>[
AlbumArtistThumbnail.md(entity: widget.album, asHero: true),
const SizedBox(height: 12),
SizedBox(
width: _cardWidth,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
widget.album.name,
style: const TextStyle(fontWeight: FontWeight.bold),
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 6),
Text(
widget.album.artistName,
style: const TextStyle(color: Colors.white54),
overflow: TextOverflow.ellipsis,
),
],
),
),
),
],
],
),
),
),
);
Expand Down
65 changes: 35 additions & 30 deletions lib/ui/widgets/artist_card.dart
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import 'package:app/models/models.dart';
import 'package:app/providers/artist_provider.dart';
import 'package:app/router.dart';
import 'package:app/ui/screens/artist_action_sheet.dart';
import 'package:app/ui/widgets/widgets.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';

class ArtistCard extends StatefulWidget {
final Artist artist;
Expand All @@ -24,37 +26,40 @@ class _ArtistCardState extends State<ArtistCard> {

@override
Widget build(BuildContext context) {
return GestureDetector(
onTapDown: (_) => setState(() => _opacity = 0.4),
onTapUp: (_) => setState(() => _opacity = 1.0),
onTapCancel: () => setState(() => _opacity = 1.0),
onTap: () => widget.router.gotoArtistDetailsScreen(
context,
artistId: widget.artist.id,
),
onLongPress: () => showArtistActionSheet(context, artist: widget.artist),
behavior: HitTestBehavior.opaque,
child: AnimatedOpacity(
duration: const Duration(milliseconds: 100),
opacity: _opacity,
child: Column(
children: <Widget>[
AlbumArtistThumbnail.md(entity: widget.artist, asHero: true),
const SizedBox(height: 12),
SizedBox(
width: _cardWidth,
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: <Widget>[
Text(
widget.artist.name,
style: const TextStyle(fontWeight: FontWeight.bold),
overflow: TextOverflow.ellipsis,
),
],
return Consumer<ArtistProvider>(
builder: (_, __, ___) => GestureDetector(
onTapDown: (_) => setState(() => _opacity = 0.4),
onTapUp: (_) => setState(() => _opacity = 1.0),
onTapCancel: () => setState(() => _opacity = 1.0),
onTap: () => widget.router.gotoArtistDetailsScreen(
context,
artistId: widget.artist.id,
),
onLongPress: () =>
showArtistActionSheet(context, artist: widget.artist),
behavior: HitTestBehavior.opaque,
child: AnimatedOpacity(
duration: const Duration(milliseconds: 100),
opacity: _opacity,
child: Column(
children: <Widget>[
AlbumArtistThumbnail.md(entity: widget.artist, asHero: true),
const SizedBox(height: 12),
SizedBox(
width: _cardWidth,
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: <Widget>[
Text(
widget.artist.name,
style: const TextStyle(fontWeight: FontWeight.bold),
overflow: TextOverflow.ellipsis,
),
],
),
),
),
],
],
),
),
),
);
Expand Down
Loading
Loading